fix: address final review findings
- commit
- f150f417d949c6593e960b81089c1ac5450369c5
- author
- brice · · 2026-06-26 10:11 UTC
- parent
- f82f76a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +243 -66
M cmd/dumber/main.go +3 -1
| @@ -883,6 +883,7 @@ func buildUIDependencies( | |||
| 883 | 883 | focusProvider := textinput.NewFocusProvider() | |
| 884 | 884 | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |
| 885 | 885 | runtimeConfig := bootstrap.NewRuntimeConfigProvider(cfg, config.GetManager()) | |
| 886 | + | localPaths := filesystem.New() | |
| 886 | 887 | ||
| 887 | 888 | uiDeps := &ui.Dependencies{ | |
| 888 | 889 | Ctx: ctx, | |
| @@ -936,7 +937,8 @@ func buildUIDependencies( | |||
| 936 | 937 | CheckUpdateUC: uc.checkUpdate, | |
| 937 | 938 | ApplyUpdateUC: uc.applyUpdate, | |
| 938 | 939 | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), | |
| 939 | - | FileSystem: filesystem.New(), | |
| 940 | + | FileSystem: localPaths, | |
| 941 | + | LocalPathResolver: localPaths, | |
| 940 | 942 | AccentFocusProvider: focusProvider, | |
| 941 | 943 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | |
| 942 | 944 | return textinput.NewGTKEntryTarget(entry) | |
| @@ -883,6 +883,7 @@ func buildUIDependencies( | |||||
| 883 | focusProvider := textinput.NewFocusProvider() | 883 | focusProvider := textinput.NewFocusProvider() | ||
| 884 | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | 884 | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | ||
| 885 | runtimeConfig := bootstrap.NewRuntimeConfigProvider(cfg, config.GetManager()) | 885 | runtimeConfig := bootstrap.NewRuntimeConfigProvider(cfg, config.GetManager()) | ||
| 886 | + | localPaths := filesystem.New() | |||
| 886 | 887 | ||||
| 887 | uiDeps := &ui.Dependencies{ | 888 | uiDeps := &ui.Dependencies{ | ||
| 888 | Ctx: ctx, | 889 | Ctx: ctx, | ||
| @@ -936,7 +937,8 @@ func buildUIDependencies( | |||||
| 936 | CheckUpdateUC: uc.checkUpdate, | 937 | CheckUpdateUC: uc.checkUpdate, | ||
| 937 | ApplyUpdateUC: uc.applyUpdate, | 938 | ApplyUpdateUC: uc.applyUpdate, | ||
| 938 | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), | 939 | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), | ||
| 939 | - | FileSystem: filesystem.New(), | 940 | + | FileSystem: localPaths, |
| 941 | + | LocalPathResolver: localPaths, | |||
| 940 | AccentFocusProvider: focusProvider, | 942 | AccentFocusProvider: focusProvider, | ||
| 941 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | 943 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | ||
| 942 | return textinput.NewGTKEntryTarget(entry) | 944 | return textinput.NewGTKEntryTarget(entry) | ||
M docs/config/index.md +14 -2
| @@ -245,6 +245,18 @@ 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,7 +275,7 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via | |||
| 263 | 275 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads | | |
| 264 | 276 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping | | |
| 265 | 277 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation | | |
| 266 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation | | |
| 278 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation | | |
| 267 | 279 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes | | |
| 268 | 280 | | `engine.webkit.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | WebKit fallback GTK renderer selection (`GSK_RENDERER`) | | |
| 269 | 281 | | `engine.webkit.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit fallback DMA-BUF renderer | | |
| @@ -283,7 +295,7 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via | |||
| 283 | 295 | ||
| 284 | 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. | |
| 285 | 297 | ||
| 286 | - | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `200.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | |
| 298 | + | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `320.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | |
| 287 | 299 | ||
| 288 | 300 | ### Legacy key migration | |
| 289 | 301 | ||
| @@ -245,6 +245,18 @@ When Noctalia rewrites the watched file, Dumber reapplies the same resolved them | |||||
| 245 | | Key | Type | Default | Description | | 245 | | Key | Type | Default | Description | | ||
| 246 | |-----|------|---------|-------------| | 246 | |-----|------|---------|-------------| | ||
| 247 | | `debug.enable_devtools` | bool | `true` | Enable browser developer tools (F12, Inspect Element) | | 247 | | `debug.enable_devtools` | bool | `true` | Enable browser developer tools (F12, Inspect Element) | | ||
| 248 | + | | `engine.cef.log_file` | string | `""` | CEF runtime log path | | |||
| 249 | + | | `engine.cef.log_severity` | int32 | `0` | CEF log severity (`0`, `1`, `2`, `3`, `4`, `99`) | | |||
| 250 | + | | `engine.cef.trace_handlers` | bool | `false` | Log CEF handler dispatch details | | |||
| 251 | + | | `engine.cef.enable_audio_handler` | bool | `true` | Enable the experimental CEF audio handler | | |||
| 252 | + | ||||
| 253 | + | ## Privacy | |||
| 254 | + | ||||
| 255 | + | | Key | Type | Default | Valid Values | Description | | |||
| 256 | + | |-----|------|---------|--------------|-------------| | |||
| 257 | + | | `engine.cookie_policy` | string | `"always"` | `always`, `no_third_party`, `never` | Cookie acceptance policy | | |||
| 258 | + | | `engine.webkit.itp_enabled` | bool | `true` | - | Enable WebKit fallback Intelligent Tracking Prevention | | |||
| 259 | + | ||||
| 248 | 260 | ||||
| 249 | ## Rendering, UI Scale & Zoom | 261 | ## Rendering, UI Scale & Zoom | ||
| 250 | 262 | ||||
| @@ -263,7 +275,7 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via | |||||
| 263 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads | | 275 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | `> 0` | Vertical scroll sensitivity multiplier; combines with `scroll_precise_multiplier` for touchpads | | ||
| 264 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping | | 276 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | `>= 0` | Maximum absolute scroll delta after scaling; 0 disables clamping | | ||
| 265 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation | | 277 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | - | Enable two-finger touchpad swipe back/forward navigation | | ||
| 266 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation | | 278 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | `> 0` | Minimum accumulated horizontal swipe delta required for navigation | |
| 267 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes | | 279 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | `> 0` | Maximum vertical-to-horizontal delta ratio allowed for navigation swipes | | ||
| 268 | | `engine.webkit.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | WebKit fallback GTK renderer selection (`GSK_RENDERER`) | | 280 | | `engine.webkit.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | WebKit fallback GTK renderer selection (`GSK_RENDERER`) | | ||
| 269 | | `engine.webkit.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit fallback DMA-BUF renderer | | 281 | | `engine.webkit.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit fallback DMA-BUF renderer | | ||
| @@ -283,7 +295,7 @@ CEF is the default browser engine. WebKitGTK remains available as a fallback via | |||||
| 283 | 295 | ||||
| 284 | `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed. | 296 | `engine.cef.input.scroll_precise_multiplier` controls touchpad/high-resolution wheel scroll speed, and `engine.cef.input.scroll_vertical_multiplier` applies an additional vertical-only scale. `engine.cef.input.touchpad_navigation_max_vertical_ratio` only filters horizontal back/forward swipe recognition; it does not tune vertical scroll speed. | ||
| 285 | 297 | ||||
| 286 | - | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `200.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. | 298 | + | `engine.cef.input.touchpad_navigation_min_delta` uses raw GTK touchpad surface units for back/forward gestures. The default `320.0` matches WebKit-style commit distance to reduce accidental navigation; raise or lower it in `config.toml` to tune gesture sensitivity. |
| 287 | 299 | ||||
| 288 | ### Legacy key migration | 300 | ### Legacy key migration | ||
| 289 | 301 | ||||
M docs/reference/configuration.md +2 -2
| @@ -70,7 +70,7 @@ | |||
| 70 | 70 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | | |
| 71 | 71 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | | |
| 72 | 72 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | | | |
| 73 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | | |
| 73 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | > 0 | | |
| 74 | 74 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | | |
| 75 | 75 | | `engine.webkit.gsk_renderer` | string | `auto` | `auto`, `opengl`, `vulkan`, `cairo` (WebKit fallback only) | | |
| 76 | 76 | | `engine.webkit.disable_dmabuf_renderer` | bool | `false` | WebKit fallback only | | |
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | ||
| 164 | 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 | 165 | ||
| 166 | - | `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`. | |
| 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 | 167 | ||
| 168 | 168 | CEF is the default engine. WebKitGTK is a fallback selected with `engine.type = "webkit"`; `engine.webkit.*` keys are WebKit-specific. | |
| 169 | 169 | ||
| @@ -70,7 +70,7 @@ | |||||
| 70 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | | 70 | | `engine.cef.input.scroll_vertical_multiplier` | float | `1.0` | > 0 | | ||
| 71 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | | 71 | | `engine.cef.input.scroll_max_delta` | int32 | `0` | >= 0 | | ||
| 72 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | | | 72 | | `engine.cef.input.touchpad_navigation_enabled` | bool | `true` | | | ||
| 73 | - | | `engine.cef.input.touchpad_navigation_min_delta` | float | `200.0` | > 0 | | 73 | + | | `engine.cef.input.touchpad_navigation_min_delta` | float | `320.0` | > 0 | |
| 74 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | | 74 | | `engine.cef.input.touchpad_navigation_max_vertical_ratio` | float | `0.5` | > 0 | | ||
| 75 | | `engine.webkit.gsk_renderer` | string | `auto` | `auto`, `opengl`, `vulkan`, `cairo` (WebKit fallback only) | | 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 | | 76 | | `engine.webkit.disable_dmabuf_renderer` | bool | `false` | WebKit fallback only | | ||
| @@ -163,7 +163,7 @@ | |||||
| 163 | 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. | 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 | 165 | ||||
| 166 | - | `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`. | 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 | 167 | ||||
| 168 | CEF is the default engine. WebKitGTK is a fallback selected with `engine.type = "webkit"`; `engine.webkit.*` keys are WebKit-specific. | 168 | CEF is the default engine. WebKitGTK is a fallback selected with `engine.type = "webkit"`; `engine.webkit.*` keys are WebKit-specific. | ||
| 169 | 169 | ||||
M internal/application/usecase/navigation_url_normalizer.go +27 -0
| @@ -34,6 +34,33 @@ func (n *NavigationURLNormalizer) Normalize(ctx context.Context, input string) s | |||
| 34 | 34 | return domainurl.Normalize(input) | |
| 35 | 35 | } | |
| 36 | 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 | + | ||
| 37 | 64 | func (n *NavigationURLNormalizer) normalize(ctx context.Context, input string) string { | |
| 38 | 65 | if n == nil { | |
| 39 | 66 | return domainurl.Normalize(input) | |
| @@ -34,6 +34,33 @@ func (n *NavigationURLNormalizer) Normalize(ctx context.Context, input string) s | |||||
| 34 | return domainurl.Normalize(input) | 34 | return domainurl.Normalize(input) | ||
| 35 | } | 35 | } | ||
| 36 | 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 | + | ||||
| 37 | func (n *NavigationURLNormalizer) normalize(ctx context.Context, input string) string { | 64 | func (n *NavigationURLNormalizer) normalize(ctx context.Context, input string) string { | ||
| 38 | if n == nil { | 65 | if n == nil { | ||
| 39 | return domainurl.Normalize(input) | 66 | return domainurl.Normalize(input) | ||
M internal/application/usecase/navigation_url_normalizer_test.go +15 -0
| @@ -51,6 +51,21 @@ func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) { | |||
| 51 | 51 | t.Fatalf("Normalize(./missing.html) = %q", got) | |
| 52 | 52 | } | |
| 53 | 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 | + | } | |
| 54 | 69 | ||
| 55 | 70 | func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) { | |
| 56 | 71 | ctx := context.Background() | |
| @@ -51,6 +51,21 @@ func TestNavigationURLNormalizerFallsBackToDomainNormalize(t *testing.T) { | |||||
| 51 | t.Fatalf("Normalize(./missing.html) = %q", got) | 51 | t.Fatalf("Normalize(./missing.html) = %q", got) | ||
| 52 | } | 52 | } | ||
| 53 | } | 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 | + | } | |||
| 54 | 69 | ||||
| 55 | func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) { | 70 | func TestNavigationURLNormalizerDoesNotProbeSchemeBearingInputs(t *testing.T) { | ||
| 56 | ctx := context.Background() | 71 | ctx := context.Background() | ||
M internal/cli/cmd/dmenu.go +9 -11
| @@ -218,22 +218,20 @@ func parseSelection(selection string) string { | |||
| 218 | 218 | return "" | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | - | // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries. | |
| 222 | 221 | // Existing local paths are resolved before URL/search fallback so dmenu keeps | |
| 223 | 222 | // the same file:// behavior as the in-app omnibox. | |
| 224 | 223 | app := GetApp() | |
| 225 | 224 | if app != nil { | |
| 226 | - | if _, _, found := domainurl.ParseBangShortcut(selection); !found { | |
| 227 | - | normalizer := app.NavigationURLNormalizer | |
| 228 | - | if normalizer == nil { | |
| 229 | - | normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths) | |
| 230 | - | } | |
| 231 | - | normalized := normalizer.Normalize(context.Background(), selection) | |
| 232 | - | if normalized != selection { | |
| 233 | - | return normalized | |
| 234 | - | } | |
| 225 | + | normalizer := app.NavigationURLNormalizer | |
| 226 | + | if normalizer == nil { | |
| 227 | + | normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths) | |
| 235 | 228 | } | |
| 236 | - | return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine) | |
| 229 | + | return normalizer.BuildNavigationURL( | |
| 230 | + | context.Background(), | |
| 231 | + | selection, | |
| 232 | + | app.Config.ShortcutURLs(), | |
| 233 | + | app.Config.DefaultSearchEngine, | |
| 234 | + | ) | |
| 237 | 235 | } | |
| 238 | 236 | ||
| 239 | 237 | // Fallback: add https:// scheme if no app context | |
| @@ -218,22 +218,20 @@ func parseSelection(selection string) string { | |||||
| 218 | return "" | 218 | return "" | ||
| 219 | } | 219 | } | ||
| 220 | 220 | ||||
| 221 | - | // Use BuildSearchURL to handle bang shortcuts, URLs, and search queries. | |||
| 222 | // Existing local paths are resolved before URL/search fallback so dmenu keeps | 221 | // Existing local paths are resolved before URL/search fallback so dmenu keeps | ||
| 223 | // the same file:// behavior as the in-app omnibox. | 222 | // the same file:// behavior as the in-app omnibox. | ||
| 224 | app := GetApp() | 223 | app := GetApp() | ||
| 225 | if app != nil { | 224 | if app != nil { | ||
| 226 | - | if _, _, found := domainurl.ParseBangShortcut(selection); !found { | 225 | + | normalizer := app.NavigationURLNormalizer |
| 227 | - | normalizer := app.NavigationURLNormalizer | 226 | + | if normalizer == nil { |
| 228 | - | if normalizer == nil { | 227 | + | normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths) |
| 229 | - | normalizer = usecase.NewNavigationURLNormalizer(app.LocalPaths) | |||
| 230 | - | } | |||
| 231 | - | normalized := normalizer.Normalize(context.Background(), selection) | |||
| 232 | - | if normalized != selection { | |||
| 233 | - | return normalized | |||
| 234 | - | } | |||
| 235 | } | 228 | } | ||
| 236 | - | return domainurl.BuildSearchURL(selection, app.Config.ShortcutURLs(), app.Config.DefaultSearchEngine) | 229 | + | return normalizer.BuildNavigationURL( |
| 230 | + | context.Background(), | |||
| 231 | + | selection, | |||
| 232 | + | app.Config.ShortcutURLs(), | |||
| 233 | + | app.Config.DefaultSearchEngine, | |||
| 234 | + | ) | |||
| 237 | } | 235 | } | ||
| 238 | 236 | ||||
| 239 | // Fallback: add https:// scheme if no app context | 237 | // Fallback: add https:// scheme if no app context | ||
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 | } | |
| @@ -41,10 +41,10 @@ func cefScaleProbeSnapshot(wv *WebView) cefScaleProbeMetrics { | |||||
| 41 | } | 41 | } | ||
| 42 | m.UserZoom = normalizeScale(wv.GetZoomLevel()) | 42 | m.UserZoom = normalizeScale(wv.GetZoomLevel()) | ||
| 43 | m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor()) | 43 | m.OSRBackingScale = normalizeScale(wv.osrBackingScaleFactor()) | ||
| 44 | - | m.InternalCEFFactor = m.UserZoom * m.OSRBackingScale | 44 | + | m.SurfaceScale = normalizeScale(wv.viewBridgeScale()) |
| 45 | + | m.InternalCEFFactor = m.UserZoom * zoomScaleRatio(m.SurfaceScale, m.OSRBackingScale) | |||
| 45 | if wv.viewBridge != nil { | 46 | if wv.viewBridge != nil { | ||
| 46 | m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size() | 47 | m.SurfaceWidth, m.SurfaceHeight = wv.viewBridge.Size() | ||
| 47 | - | m.SurfaceScale = normalizeScale(float64(wv.viewBridge.DeviceScaleFactor())) | |||
| 48 | } | 48 | } | ||
| 49 | return m | 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 | ||
| @@ -214,12 +214,12 @@ type WebView struct { | |||||
| 214 | loadDiagLastLoadStateAt time.Time | 214 | loadDiagLastLoadStateAt time.Time | ||
| 215 | 215 | ||||
| 216 | // Atomic state. | 216 | // Atomic state. | ||
| 217 | - | destroyed atomic.Bool | 217 | + | destroyed atomic.Bool |
| 218 | - | fullscreen atomic.Bool | 218 | + | fullscreen atomic.Bool |
| 219 | - | generation atomic.Uint64 | 219 | + | generation atomic.Uint64 |
| 220 | - | audioPlaying atomic.Bool | 220 | + | audioPlaying atomic.Bool |
| 221 | - | zoomFactor atomic.Value // float64, initialized to 1.0 | 221 | + | zoomFactor atomic.Value // float64, initialized to 1.0 |
| 222 | - | lastAppliedOSRBackingScaleBits atomic.Uint64 | 222 | + | lastAppliedZoomScaleRatioBits atomic.Uint64 |
| 223 | 223 | ||||
| 224 | // Browser creation defaults copied from the factory so native popup shells | 224 | // Browser creation defaults copied from the factory so native popup shells | ||
| 225 | // can apply the same settings in OnBeforePopup. | 225 | // can apply the same settings in OnBeforePopup. | ||
| @@ -491,15 +491,23 @@ func cefZoomFromFactor(factor float64) float64 { | |||||
| 491 | return math.Log(factor) / math.Log(chromiumZoomBase) | 491 | return math.Log(factor) / math.Log(chromiumZoomBase) | ||
| 492 | } | 492 | } | ||
| 493 | 493 | ||||
| 494 | - | func cefZoomFromPageAndBackingFactor(pageZoom, backingScale float64) float64 { | 494 | + | func zoomScaleRatio(surfaceScale, backingScale float64) float64 { |
| 495 | - | return cefZoomFromFactor(pageZoom / normalizeScale(backingScale)) | 495 | + | backingScale = normalizeScale(backingScale) |
| 496 | + | if backingScale <= 1 { | |||
| 497 | + | return 1 | |||
| 498 | + | } | |||
| 499 | + | return normalizeScale(surfaceScale) / backingScale | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func cefZoomFromPageAndScaleFactors(pageZoom, surfaceScale, backingScale float64) float64 { | |||
| 503 | + | return cefZoomFromFactor(pageZoom * zoomScaleRatio(surfaceScale, backingScale)) | |||
| 496 | } | 504 | } | ||
| 497 | 505 | ||||
| 498 | - | func pageZoomFromCEFAndBackingLevel(level, backingScale float64) float64 { | 506 | + | func pageZoomFromCEFAndScaleLevel(level, surfaceScale, backingScale float64) float64 { |
| 499 | - | return factorFromCEFZoom(level) * normalizeScale(backingScale) | 507 | + | return factorFromCEFZoom(level) / zoomScaleRatio(surfaceScale, backingScale) |
| 500 | } | 508 | } | ||
| 501 | 509 | ||||
| 502 | - | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, backingScale float64) { | 510 | + | func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, surfaceScale, backingScale float64) { |
| 503 | host.SetZoomLevel(cefLevel) | 511 | host.SetZoomLevel(cefLevel) | ||
| 504 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | 512 | // Force CEF to produce a new frame at the new zoom level. In OSR mode, | ||
| 505 | // SetZoomLevel changes the Blink layout zoom but doesn't guarantee a | 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 | // SynchronizeVisualProperties cycle, which makes the renderer produce | 516 | // SynchronizeVisualProperties cycle, which makes the renderer produce | ||
| 509 | // a new compositor frame at the new zoom level. | 517 | // a new compositor frame at the new zoom level. | ||
| 510 | host.NotifyScreenInfoChanged() | 518 | host.NotifyScreenInfoChanged() | ||
| 511 | - | wv.recordAppliedOSRBackingScale(backingScale) | 519 | + | wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale) |
| 512 | // Zoom is applied asynchronously in the renderer process. Request a couple | 520 | // Zoom is applied asynchronously in the renderer process. Request a couple | ||
| 513 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | 521 | // of follow-up refreshes on the CEF UI thread so OSR captures the updated | ||
| 514 | // compositor frame after the zoom IPC has been processed. | 522 | // compositor frame after the zoom IPC has been processed. | ||
| @@ -541,14 +549,16 @@ func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error { | |||||
| 541 | if host == nil { | 549 | if host == nil { | ||
| 542 | return errNoBrowser | 550 | return errNoBrowser | ||
| 543 | } | 551 | } | ||
| 552 | + | surfaceScale := wv.viewBridgeScale() | |||
| 544 | backingScale := wv.osrBackingScaleFactor() | 553 | backingScale := wv.osrBackingScaleFactor() | ||
| 545 | - | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | 554 | + | cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale) |
| 546 | logging.FromContext(wv.ctx).Debug(). | 555 | logging.FromContext(wv.ctx).Debug(). | ||
| 547 | Float64("factor", factor). | 556 | Float64("factor", factor). | ||
| 548 | Float64("cef_level", cefLevel). | 557 | Float64("cef_level", cefLevel). | ||
| 549 | Float64("osr_backing_scale", backingScale). | 558 | Float64("osr_backing_scale", backingScale). | ||
| 559 | + | Float64("surface_scale", surfaceScale). | |||
| 550 | Msg("cef: SetZoomLevel") | 560 | Msg("cef: SetZoomLevel") | ||
| 551 | - | wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale) | 561 | + | wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale) |
| 552 | wv.zoomFactor.Store(factor) | 562 | wv.zoomFactor.Store(factor) | ||
| 553 | return nil | 563 | return nil | ||
| 554 | } | 564 | } | ||
| @@ -1665,23 +1675,23 @@ func (wv *WebView) osrBackingScaleFactor() float64 { | |||||
| 1665 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | 1675 | return normalizeScale(wv.viewBridge.OSRBackingScaleFactor()) | ||
| 1666 | } | 1676 | } | ||
| 1667 | 1677 | ||||
| 1668 | - | func (wv *WebView) recordAppliedOSRBackingScale(scale float64) { | 1678 | + | func (wv *WebView) recordAppliedZoomScaleRatio(surfaceScale, backingScale float64) { |
| 1669 | if wv == nil { | 1679 | if wv == nil { | ||
| 1670 | return | 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 | if wv == nil { | 1686 | if wv == nil { | ||
| 1677 | return false | 1687 | return false | ||
| 1678 | } | 1688 | } | ||
| 1679 | - | normalized := normalizeScale(scale) | 1689 | + | ratio := zoomScaleRatio(surfaceScale, backingScale) |
| 1680 | - | bits := wv.lastAppliedOSRBackingScaleBits.Load() | 1690 | + | bits := wv.lastAppliedZoomScaleRatioBits.Load() |
| 1681 | if bits == 0 { | 1691 | if bits == 0 { | ||
| 1682 | return true | 1692 | return true | ||
| 1683 | } | 1693 | } | ||
| 1684 | - | return math.Float64frombits(bits) != normalized | 1694 | + | return math.Float64frombits(bits) != ratio |
| 1685 | } | 1695 | } | ||
| 1686 | 1696 | ||||
| 1687 | func (wv *WebView) handleMiddleClickFromBridge() bool { | 1697 | func (wv *WebView) handleMiddleClickFromBridge() bool { | ||
| @@ -1982,16 +1992,18 @@ func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |||||
| 1982 | if host == nil { | 1992 | if host == nil { | ||
| 1983 | return | 1993 | return | ||
| 1984 | } | 1994 | } | ||
| 1995 | + | surfaceScale := wv.viewBridgeScale() | |||
| 1985 | backingScale := wv.osrBackingScaleFactor() | 1996 | backingScale := wv.osrBackingScaleFactor() | ||
| 1986 | actualLevel := host.GetZoomLevel() | 1997 | actualLevel := host.GetZoomLevel() | ||
| 1987 | logging.FromContext(wv.ctx).Debug(). | 1998 | logging.FromContext(wv.ctx).Debug(). | ||
| 1988 | Int64("delay_ms", delayMs). | 1999 | Int64("delay_ms", delayMs). | ||
| 1989 | Float64("expected_factor", expectedFactor). | 2000 | Float64("expected_factor", expectedFactor). | ||
| 1990 | Float64("expected_cef_level", expectedLevel). | 2001 | Float64("expected_cef_level", expectedLevel). | ||
| 1991 | - | Float64("actual_factor", pageZoomFromCEFAndBackingLevel(actualLevel, backingScale)). | 2002 | + | Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)). |
| 1992 | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | 2003 | Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)). | ||
| 1993 | Float64("actual_cef_level", actualLevel). | 2004 | Float64("actual_cef_level", actualLevel). | ||
| 1994 | Float64("osr_backing_scale", backingScale). | 2005 | Float64("osr_backing_scale", backingScale). | ||
| 2006 | + | Float64("surface_scale", surfaceScale). | |||
| 1995 | Msg("cef: zoom level readback") | 2007 | Msg("cef: zoom level readback") | ||
| 1996 | })) | 2008 | })) | ||
| 1997 | if task == nil { | 2009 | if task == nil { | ||
| @@ -2012,14 +2024,16 @@ func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) { | |||||
| 2012 | return | 2024 | return | ||
| 2013 | } | 2025 | } | ||
| 2014 | factor := wv.GetZoomLevel() | 2026 | factor := wv.GetZoomLevel() | ||
| 2027 | + | surfaceScale := wv.viewBridgeScale() | |||
| 2015 | backingScale := wv.osrBackingScaleFactor() | 2028 | backingScale := wv.osrBackingScaleFactor() | ||
| 2016 | - | cefLevel := cefZoomFromPageAndBackingFactor(factor, backingScale) | 2029 | + | cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale) |
| 2017 | - | wv.applyCEFZoomLevel(host, factor, cefLevel, backingScale) | 2030 | + | wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale) |
| 2018 | logging.FromContext(wv.ctx).Debug(). | 2031 | logging.FromContext(wv.ctx).Debug(). | ||
| 2019 | Str("reason", reason). | 2032 | Str("reason", reason). | ||
| 2020 | Float64("factor", factor). | 2033 | Float64("factor", factor). | ||
| 2021 | Float64("cef_level", cefLevel). | 2034 | Float64("cef_level", cefLevel). | ||
| 2022 | Float64("osr_backing_scale", backingScale). | 2035 | Float64("osr_backing_scale", backingScale). | ||
| 2036 | + | Float64("surface_scale", surfaceScale). | |||
| 2023 | Msg("cef: reapplied zoom after OSR backing scale change") | 2037 | Msg("cef: reapplied zoom after OSR backing scale change") | ||
| 2024 | } | 2038 | } | ||
| 2025 | 2039 | ||||
M internal/infrastructure/cef/webview_handlers.go +2 -2
| @@ -783,7 +783,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 783 | 783 | ||
| 784 | 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) | |
| @@ -783,7 +783,7 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||||
| 783 | 783 | ||||
| 784 | h.wv.browser = browser | 784 | h.wv.browser = browser | ||
| 785 | h.wv.host = host | 785 | h.wv.host = host | ||
| 786 | - | h.wv.lastAppliedOSRBackingScaleBits.Store(0) | 786 | + | h.wv.lastAppliedZoomScaleRatioBits.Store(0) |
| 787 | h.wv.pendingCreate = nil | 787 | h.wv.pendingCreate = nil | ||
| 788 | bridge := h.wv.viewBridge | 788 | bridge := h.wv.viewBridge | ||
| 789 | h.wv.inputAttached = bridge == nil | 789 | h.wv.inputAttached = bridge == nil | ||
| @@ -901,7 +901,7 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||||
| 901 | h.wv.mu.Lock() | 901 | h.wv.mu.Lock() | ||
| 902 | h.wv.browser = nil | 902 | h.wv.browser = nil | ||
| 903 | h.wv.host = nil | 903 | h.wv.host = nil | ||
| 904 | - | h.wv.lastAppliedOSRBackingScaleBits.Store(0) | 904 | + | h.wv.lastAppliedZoomScaleRatioBits.Store(0) |
| 905 | h.wv.inputAttached = false | 905 | h.wv.inputAttached = false | ||
| 906 | if h.wv.findCtrl != nil { | 906 | if h.wv.findCtrl != nil { | ||
| 907 | h.wv.findCtrl.setHost(nil) | 907 | h.wv.findCtrl.setHost(nil) | ||
M internal/infrastructure/cef/webview_viewport.go +3 -2
| @@ -500,7 +500,7 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||
| 500 | 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) | |
| @@ -500,7 +500,7 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||||
| 500 | if host == nil || wv == nil { | 500 | if host == nil || wv == nil { | ||
| 501 | return | 501 | return | ||
| 502 | } | 502 | } | ||
| 503 | - | if !wv.shouldReapplyZoomForBackingScale(wv.osrBackingScaleFactor()) { | 503 | + | if !wv.shouldReapplyZoomForScaleRatio(wv.viewBridgeScale(), wv.osrBackingScaleFactor()) { |
| 504 | return | 504 | return | ||
| 505 | } | 505 | } | ||
| 506 | task := cefNewTask(cefTaskFunc(func() { | 506 | task := cefNewTask(cefTaskFunc(func() { | ||
| @@ -513,8 +513,9 @@ func (wv *WebView) syncZoomForBackingScaleOnCEFUIThread(host purecef.BrowserHost | |||||
| 513 | if currentHost != host { | 513 | if currentHost != host { | ||
| 514 | return | 514 | return | ||
| 515 | } | 515 | } | ||
| 516 | + | surfaceScale := wv.viewBridgeScale() | |||
| 516 | backingScale := wv.osrBackingScaleFactor() | 517 | backingScale := wv.osrBackingScaleFactor() | ||
| 517 | - | if !wv.shouldReapplyZoomForBackingScale(backingScale) { | 518 | + | if !wv.shouldReapplyZoomForScaleRatio(surfaceScale, backingScale) { |
| 518 | return | 519 | return | ||
| 519 | } | 520 | } | ||
| 520 | wv.reapplyCurrentZoomForBackingScale(reason) | 521 | wv.reapplyCurrentZoomForBackingScale(reason) | ||
M internal/infrastructure/cef/webview_zoom_test.go +53 -9
| @@ -18,14 +18,58 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||
| 18 | 18 | } | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | func TestZoomConversionsCompensateDeviceSizedOSRBackingInternally(t *testing.T) { | |
| 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 | } | |
| @@ -18,14 +18,58 @@ func TestZoomConversionsRoundTrip(t *testing.T) { | |||||
| 18 | } | 18 | } | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | func TestZoomConversionsCompensateDeviceSizedOSRBackingInternally(t *testing.T) { | 21 | + | func TestZoomConversionsCompensateDeviceSizedOSRBackingAndSurfaceScale(t *testing.T) { |
| 22 | - | for _, backing := range []float64{1.0, 1.25, 1.44, 2.0} { | 22 | + | tests := []struct { |
| 23 | - | for _, pageZoom := range []float64{1.0, 1.75} { | 23 | + | name string |
| 24 | - | t.Run(fmt.Sprintf("backing_%.2f_page_%.2f", backing, pageZoom), func(t *testing.T) { | 24 | + | surfaceScale float64 |
| 25 | - | level := cefZoomFromPageAndBackingFactor(pageZoom, backing) | 25 | + | backingScale float64 |
| 26 | - | assert.InDelta(t, pageZoom/backing, factorFromCEFZoom(level), 1e-9) | 26 | + | pageZoom float64 |
| 27 | - | assert.InDelta(t, pageZoom, pageZoomFromCEFAndBackingLevel(level, backing), 1e-9) | 27 | + | wantInternal float64 |
| 28 | - | }) | 28 | + | }{ |
| 29 | - | } | 29 | + | { |
| 30 | + | name: "fractional_hidpi_keeps_100_percent_internal_zoom", | |||
| 31 | + | surfaceScale: 1.2, | |||
| 32 | + | backingScale: 1.2, | |||
| 33 | + | pageZoom: 1.0, | |||
| 34 | + | wantInternal: 1.0, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "integer_hidpi_keeps_requested_user_zoom", | |||
| 38 | + | surfaceScale: 2.0, | |||
| 39 | + | backingScale: 2.0, | |||
| 40 | + | pageZoom: 1.2, | |||
| 41 | + | wantInternal: 1.2, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "normal_osr_hidpi_keeps_requested_user_zoom", | |||
| 45 | + | surfaceScale: 1.2, | |||
| 46 | + | backingScale: 1.0, | |||
| 47 | + | pageZoom: 1.0, | |||
| 48 | + | wantInternal: 1.0, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "device_sized_backing_remains_compensated", | |||
| 52 | + | surfaceScale: 1.25, | |||
| 53 | + | backingScale: 2.0, | |||
| 54 | + | pageZoom: 1.2, | |||
| 55 | + | wantInternal: 0.75, | |||
| 56 | + | }, | |||
| 30 | } | 57 | } | ||
| 58 | + | ||||
| 59 | + | for _, tt := range tests { | |||
| 60 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 61 | + | level := cefZoomFromPageAndScaleFactors(tt.pageZoom, tt.surfaceScale, tt.backingScale) | |||
| 62 | + | assert.InDelta(t, tt.wantInternal, factorFromCEFZoom(level), 1e-9) | |||
| 63 | + | assert.InDelta(t, tt.pageZoom, pageZoomFromCEFAndScaleLevel(level, tt.surfaceScale, tt.backingScale), 1e-9) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestZoomReapplyGuardTracksEffectiveScaleRatio(t *testing.T) { | |||
| 69 | + | wv := &WebView{} | |||
| 70 | + | ||||
| 71 | + | wv.recordAppliedZoomScaleRatio(1.2, 1.2) | |||
| 72 | + | ||||
| 73 | + | assert.False(t, wv.shouldReapplyZoomForScaleRatio(2.0, 2.0), "same effective ratio should not reapply") | |||
| 74 | + | assert.True(t, wv.shouldReapplyZoomForScaleRatio(1.2, 2.0), "changed active-backing ratio should reapply") | |||
| 31 | } | 75 | } | ||
M internal/infrastructure/config/loader.go +20 -2
| @@ -321,9 +321,16 @@ func (m *Manager) checkLegacyFormat() error { | |||
| 321 | 321 | } | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 324 | - | hasEngineSection := m.viper.InConfig("engine.type") | |
| 324 | + | hasEngineSection := m.hasEngineSectionInConfig() | |
| 325 | 325 | ||
| 326 | - | 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 { | |
| 327 | 334 | return fmt.Errorf( | |
| 328 | 335 | "config format outdated: legacy engine keys in %s have moved to "+ | |
| 329 | 336 | "[engine]/[engine.webkit] — run \"dumber config migrate\" "+ | |
| @@ -334,6 +341,17 @@ func (m *Manager) checkLegacyFormat() error { | |||
| 334 | 341 | return nil | |
| 335 | 342 | } | |
| 336 | 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 | + | ||
| 337 | 355 | func (m *Manager) unmarshalConfig() (*Config, error) { | |
| 338 | 356 | config := &Config{} | |
| 339 | 357 | if err := m.viper.Unmarshal(config); err != nil { | |
| @@ -321,9 +321,16 @@ func (m *Manager) checkLegacyFormat() error { | |||||
| 321 | } | 321 | } | ||
| 322 | } | 322 | } | ||
| 323 | 323 | ||||
| 324 | - | hasEngineSection := m.viper.InConfig("engine.type") | 324 | + | hasEngineSection := m.hasEngineSectionInConfig() |
| 325 | 325 | ||||
| 326 | - | 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 { | |||
| 327 | return fmt.Errorf( | 334 | return fmt.Errorf( | ||
| 328 | "config format outdated: legacy engine keys in %s have moved to "+ | 335 | "config format outdated: legacy engine keys in %s have moved to "+ | ||
| 329 | "[engine]/[engine.webkit] — run \"dumber config migrate\" "+ | 336 | "[engine]/[engine.webkit] — run \"dumber config migrate\" "+ | ||
| @@ -334,6 +341,17 @@ func (m *Manager) checkLegacyFormat() error { | |||||
| 334 | return nil | 341 | return nil | ||
| 335 | } | 342 | } | ||
| 336 | 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 | + | ||||
| 337 | func (m *Manager) unmarshalConfig() (*Config, error) { | 355 | func (m *Manager) unmarshalConfig() (*Config, error) { | ||
| 338 | config := &Config{} | 356 | config := &Config{} | ||
| 339 | if err := m.viper.Unmarshal(config); err != nil { | 357 | if err := m.viper.Unmarshal(config); err != nil { | ||
M internal/infrastructure/config/loader_legacy_test.go +16 -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()} | |
| @@ -29,6 +29,22 @@ func TestCheckLegacyFormat_OldSectionsWithEngine_ReturnsError(t *testing.T) { | |||||
| 29 | require.Error(t, err) | 29 | require.Error(t, err) | ||
| 30 | require.Contains(t, err.Error(), "dumber config migrate") | 30 | require.Contains(t, err.Error(), "dumber config migrate") | ||
| 31 | } | 31 | } | ||
| 32 | + | func TestCheckLegacyFormat_MixedEngineSectionReturnsMixedConfigError(t *testing.T) { | |||
| 33 | + | dir := t.TempDir() | |||
| 34 | + | configPath := filepath.Join(dir, "config.toml") | |||
| 35 | + | require.NoError(t, os.WriteFile(configPath, []byte("[engine]\nprofile = \"balanced\"\n\n[rendering]\ngsk_renderer = \"auto\"\n"), 0o600)) | |||
| 36 | + | ||||
| 37 | + | m := &Manager{viper: viper.New()} | |||
| 38 | + | m.viper.SetConfigFile(configPath) | |||
| 39 | + | m.viper.SetConfigType("toml") | |||
| 40 | + | require.NoError(t, m.viper.ReadInConfig()) | |||
| 41 | + | ||||
| 42 | + | err := m.checkLegacyFormat() | |||
| 43 | + | ||||
| 44 | + | require.Error(t, err) | |||
| 45 | + | require.Contains(t, err.Error(), "mixed old/new config") | |||
| 46 | + | require.NotContains(t, err.Error(), "dumber config migrate") | |||
| 47 | + | } | |||
| 32 | 48 | ||||
| 33 | func TestCheckLegacyFormat_FreshConfig_ReturnsNoError(t *testing.T) { | 49 | func TestCheckLegacyFormat_FreshConfig_ReturnsNoError(t *testing.T) { | ||
| 34 | m := &Manager{viper: viper.New()} | 50 | m := &Manager{viper: viper.New()} | ||
M internal/infrastructure/config/migrate_engine.go +10 -0
| @@ -156,6 +156,15 @@ func hasLegacyEngineAlias(raw map[string]any, alias legacyEngineAlias) bool { | |||
| 156 | 156 | return exists | |
| 157 | 157 | } | |
| 158 | 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 | + | } | |
| 167 | + | ||
| 159 | 168 | // MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit]. | |
| 160 | 169 | // Returns true if migration was performed, false if already migrated or no old sections found. | |
| 161 | 170 | func MigrateToEngineConfig(configFile string) (bool, error) { | |
| @@ -245,6 +254,7 @@ func applyEngineMappings(raw map[string]any) { | |||
| 245 | 254 | if val == nil { | |
| 246 | 255 | continue | |
| 247 | 256 | } | |
| 257 | + | val = canonicalLegacyEngineValue(alias, val) | |
| 248 | 258 | switch alias.targetSection { | |
| 249 | 259 | case engineTargetSection: | |
| 250 | 260 | engineMap[alias.targetField] = val | |
| @@ -156,6 +156,15 @@ func hasLegacyEngineAlias(raw map[string]any, alias legacyEngineAlias) bool { | |||||
| 156 | return exists | 156 | return exists | ||
| 157 | } | 157 | } | ||
| 158 | 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 | + | } | |||
| 167 | + | ||||
| 159 | // MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit]. | 168 | // MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit]. | ||
| 160 | // Returns true if migration was performed, false if already migrated or no old sections found. | 169 | // Returns true if migration was performed, false if already migrated or no old sections found. | ||
| 161 | func MigrateToEngineConfig(configFile string) (bool, error) { | 170 | func MigrateToEngineConfig(configFile string) (bool, error) { | ||
| @@ -245,6 +254,7 @@ func applyEngineMappings(raw map[string]any) { | |||||
| 245 | if val == nil { | 254 | if val == nil { | ||
| 246 | continue | 255 | continue | ||
| 247 | } | 256 | } | ||
| 257 | + | val = canonicalLegacyEngineValue(alias, val) | |||
| 248 | switch alias.targetSection { | 258 | switch alias.targetSection { | ||
| 249 | case engineTargetSection: | 259 | case engineTargetSection: | ||
| 250 | engineMap[alias.targetField] = val | 260 | engineMap[alias.targetField] = val | ||
M internal/infrastructure/config/migrate_engine_test.go +2 -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"] | |
| @@ -89,8 +89,8 @@ func TestMigrateToEngineConfig_OldFormat(t *testing.T) { | |||||
| 89 | assert.Equal(t, int64(50), engine["zoom_cache_size"]) | 89 | assert.Equal(t, int64(50), engine["zoom_cache_size"]) | ||
| 90 | assert.Equal(t, int64(2), engine["pool_prewarm_count"]) | 90 | assert.Equal(t, int64(2), engine["pool_prewarm_count"]) | ||
| 91 | 91 | ||||
| 92 | - | // [privacy] -> [engine] universal field | 92 | + | // [privacy] -> [engine] universal field, translated to canonical schema value |
| 93 | - | assert.Equal(t, "accept_all", engine["cookie_policy"]) | 93 | + | assert.Equal(t, "always", engine["cookie_policy"]) |
| 94 | 94 | ||||
| 95 | // [engine.webkit] section | 95 | // [engine.webkit] section | ||
| 96 | webkitAny, ok := engine["webkit"] | 96 | webkitAny, ok := engine["webkit"] | ||
M internal/ui/app.go +1 -1
| @@ -1252,7 +1252,7 @@ func NewStandaloneOmniboxRuntime( | |||
| 1252 | 1252 | } | |
| 1253 | 1253 | var localPathResolver port.LocalPathResolver | |
| 1254 | 1254 | if deps != nil { | |
| 1255 | - | localPathResolver, _ = deps.FileSystem.(port.LocalPathResolver) | |
| 1255 | + | localPathResolver = deps.LocalPathResolver | |
| 1256 | 1256 | } | |
| 1257 | 1257 | navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver) | |
| 1258 | 1258 | normalizeNavigationURL := func(navCtx context.Context, input string) string { | |
| @@ -1252,7 +1252,7 @@ func NewStandaloneOmniboxRuntime( | |||||
| 1252 | } | 1252 | } | ||
| 1253 | var localPathResolver port.LocalPathResolver | 1253 | var localPathResolver port.LocalPathResolver | ||
| 1254 | if deps != nil { | 1254 | if deps != nil { | ||
| 1255 | - | localPathResolver, _ = deps.FileSystem.(port.LocalPathResolver) | 1255 | + | localPathResolver = deps.LocalPathResolver |
| 1256 | } | 1256 | } | ||
| 1257 | navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver) | 1257 | navigationURLNormalizer := usecase.NewNavigationURLNormalizer(localPathResolver) | ||
| 1258 | normalizeNavigationURL := func(navCtx context.Context, input string) string { | 1258 | normalizeNavigationURL := func(navCtx context.Context, input string) string { | ||
M internal/ui/component/omnibox.go +1 -7
| @@ -2698,13 +2698,7 @@ func (o *Omnibox) buildURL(text string) string { | |||
| 2698 | 2698 | if o.shortcutsUC != nil { | |
| 2699 | 2699 | shortcutURLs = o.shortcutsUC.ShortcutURLs() | |
| 2700 | 2700 | } | |
| 2701 | - | if _, _, found := url.ParseBangShortcut(text); !found && o.normalizeNavigationURL != nil { | |
| 2702 | - | normalized := o.normalizeNavigationURL(o.ctx, text) | |
| 2703 | - | if normalized != text { | |
| 2704 | - | return normalized | |
| 2705 | - | } | |
| 2706 | - | } | |
| 2707 | - | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) | |
| 2701 | + | return usecase.BuildNavigationURL(o.ctx, text, o.normalizeNavigationURL, shortcutURLs, o.defaultSearch) | |
| 2708 | 2702 | } | |
| 2709 | 2703 | ||
| 2710 | 2704 | // toggleViewMode switches between history and favorites. | |
| @@ -2698,13 +2698,7 @@ func (o *Omnibox) buildURL(text string) string { | |||||
| 2698 | if o.shortcutsUC != nil { | 2698 | if o.shortcutsUC != nil { | ||
| 2699 | shortcutURLs = o.shortcutsUC.ShortcutURLs() | 2699 | shortcutURLs = o.shortcutsUC.ShortcutURLs() | ||
| 2700 | } | 2700 | } | ||
| 2701 | - | if _, _, found := url.ParseBangShortcut(text); !found && o.normalizeNavigationURL != nil { | 2701 | + | return usecase.BuildNavigationURL(o.ctx, text, o.normalizeNavigationURL, shortcutURLs, o.defaultSearch) |
| 2702 | - | normalized := o.normalizeNavigationURL(o.ctx, text) | |||
| 2703 | - | if normalized != text { | |||
| 2704 | - | return normalized | |||
| 2705 | - | } | |||
| 2706 | - | } | |||
| 2707 | - | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) | |||
| 2708 | } | 2702 | } | ||
| 2709 | 2703 | ||||
| 2710 | // toggleViewMode switches between history and favorites. | 2704 | // toggleViewMode switches between history and favorites. | ||
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 | |
| @@ -89,6 +89,8 @@ type Dependencies struct { | |||||
| 89 | SessionSpawner port.SessionSpawner | 89 | SessionSpawner port.SessionSpawner | ||
| 90 | // FileSystem provides file operations (e.g., for download deduplication). | 90 | // FileSystem provides file operations (e.g., for download deduplication). | ||
| 91 | FileSystem port.FileSystem | 91 | FileSystem port.FileSystem | ||
| 92 | + | // LocalPathResolver resolves user-entered local filesystem paths for navigation. | |||
| 93 | + | LocalPathResolver port.LocalPathResolver | |||
| 92 | 94 | ||||
| 93 | // Update management | 95 | // Update management | ||
| 94 | CheckUpdateUC *usecase.CheckUpdateUseCase | 96 | CheckUpdateUC *usecase.CheckUpdateUseCase | ||
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 := "" | |
| @@ -3,11 +3,35 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | stdurl "net/url" | |||
| 7 | + | "path/filepath" | |||
| 6 | "testing" | 8 | "testing" | ||
| 7 | 9 | ||||
| 8 | urlutil "github.com/bnema/dumber/internal/domain/url" | 10 | urlutil "github.com/bnema/dumber/internal/domain/url" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 13 | + | type standaloneTestLocalPathResolver struct { | |||
| 14 | + | paths map[string]string | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (r standaloneTestLocalPathResolver) ResolveExistingPath(_ context.Context, input string) (string, bool, error) { | |||
| 18 | + | absPath, ok := r.paths[input] | |||
| 19 | + | return absPath, ok, nil | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestNewStandaloneOmniboxRuntimeUsesExplicitLocalPathResolver(t *testing.T) { | |||
| 23 | + | absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html") | |||
| 24 | + | runtime := NewStandaloneOmniboxRuntime(context.Background(), &Dependencies{ | |||
| 25 | + | LocalPathResolver: standaloneTestLocalPathResolver{paths: map[string]string{"page.html": absFile}}, | |||
| 26 | + | }, nil) | |||
| 27 | + | ||||
| 28 | + | got := runtime.OmniboxCfg.NormalizeNavigationURL(context.Background(), "page.html") | |||
| 29 | + | want := (&stdurl.URL{Scheme: "file", Path: absFile}).String() | |||
| 30 | + | if got != want { | |||
| 31 | + | t.Fatalf("NormalizeNavigationURL(page.html) = %q, want %q", got, want) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 11 | func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) { | 35 | func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) { | ||
| 12 | browserURL := "" | 36 | browserURL := "" | ||
| 13 | externalURL := "" | 37 | externalURL := "" | ||