Merge pull request #162 from bnema/feat/stability-performance-multi-agent-2026-02-07
feat: stability/performance follow-ups (clean restart)
90 files · +5831 -907
M .gitignore +3 -0
| @@ -107,3 +107,6 @@ aur/*/*.pkg.tar.zst | |||||
| 107 | aur/*/LICENSE | 107 | aur/*/LICENSE | ||
| 108 | aur/*/dev.bnema.Dumber.desktop | 108 | aur/*/dev.bnema.Dumber.desktop | ||
| 109 | aur/*/logo-512.png | 109 | aur/*/logo-512.png | ||
| 110 | + | ||||
| 111 | + | # Planning/runbook notes stored outside product docs | |||
| 112 | + | docs/reference/stability-performance-multi-agent-runbook-*.md | |||
M CHANGELOG.md +51 -0
| @@ -2,6 +2,57 @@ | |||||
| 2 | 2 | ||||
| 3 | All notable changes to this project will be documented in this file. | 3 | All notable changes to this project will be documented in this file. | ||
| 4 | 4 | ||||
| 5 | + | ## [Unreleased] | |||
| 6 | + | ||||
| 7 | + | ### Added | |||
| 8 | + | ||||
| 9 | + | - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths. | |||
| 10 | + | - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling. | |||
| 11 | + | - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior. | |||
| 12 | + | ||||
| 13 | + | ### Changed | |||
| 14 | + | ||||
| 15 | + | - **WebKit/puregotk compatibility refresh**: Updated bindings and call sites for `github.com/bnema/puregotk-webkit v0.0.10` and latest puregotk replacement, including signal ID/type handling updates. | |||
| 16 | + | - **Dependencies updated**: Bumped key runtime/developer dependencies, including `go-sqlite3`, `bubbles`, and related transitive modules. | |||
| 17 | + | - **Omnibox behavior tuning**: Refined ghost suggestion ranking/selection behavior and dynamic result sizing tied to pane height. | |||
| 18 | + | - **Autocomplete architecture cleanup**: Centralized completion flow and ghost positioning helpers for more consistent behavior across async paths. | |||
| 19 | + | - **Omnibox keyboard/navigation UX**: Added `Alt+h/j/k/l` pane navigation while omnibox is open and improved chrome height scaling/debouncing on GTK main thread. | |||
| 20 | + | - **Config normalization hardening**: Normalization now coerces invalid profile/appearance values to safe defaults. | |||
| 21 | + | - **Loading skeleton redesign**: Reduced logo from 512px to 256px, added version label below a discrete 32px spinner. Sizes extracted to constants so tests use `mock.Anything` and don't break on dimension changes. | |||
| 22 | + | - **Aggressive WebView reuse policy**: `reusePolicyAggressive` now actually differs from `reusePolicySafe` by allowing reuse regardless of navigation activity, relying on `ResetForPoolReuse` to clear state. | |||
| 23 | + | ||||
| 24 | + | ### Fixed | |||
| 25 | + | ||||
| 26 | + | - **Hover focus stealing from new split panes**: New pane creation now locks hover focus until the user intentionally moves the mouse, replacing the 300ms time-based suppression that could expire before GTK layout completes. Synthetic enter events from widget rearrangement no longer steal focus from freshly created panes. | |||
| 27 | + | - **Stacked pane title desync on navigation**: Titles in the stacked title bar now sync immediately on `LoadCommitted` and when switching panes, preventing stale titles after background navigation. | |||
| 28 | + | - **Session marker file accumulation**: `writeShutdownMarker` now removes the matching startup marker (embedding its timestamp for classification), and a new `sweepPairedMarkers` sweep in background cleanup removes old marker trios past the configured age. | |||
| 29 | + | - **Retry delay exceeding max**: `retryDelayForAttempt` now clamps the final delay (base + jitter) to `retryMaxDelay` so backoff never overshoots the hard cap. | |||
| 30 | + | - **Inconsistent WebView termination in pool**: `PrewarmFirst`, `PrewarmAsync`, `prewarmSync`, and `RefreshScripts` now call `DestroyWithPolicy(terminatePolicy)` instead of bare `Destroy()`, matching `Release` behavior. | |||
| 31 | + | - **Pool metrics counted on failed creation**: `acquireCount`, `acquirePoolMisses`, and `acquireTotalNanos` are now incremented only after `createWebView` succeeds. | |||
| 32 | + | - **Redundant crash page URI sanitization**: Removed double `sanitizeCrashPageOriginalURI` call inside `buildCrashPageHTML`; the caller already sanitizes. | |||
| 33 | + | - **Racy skeleton version global**: Replaced plain `string` with `atomic.Value` so concurrent reads/writes to `skeletonVersion` are safe. | |||
| 34 | + | - **Inline ghost suggestion stability**: Reworked ghostwriter flow to keep suggestions selected (not auto-committed), with stronger token guards and stale-callback protection. | |||
| 35 | + | - **GTK thread-safety in omnibox**: Removed unsafe off-main-thread widget reads in async flows. | |||
| 36 | + | - **Review follow-ups (Copilot/CodeRabbit)**: Fixed custom-scheme GET handling for empty WebKit methods, synchronized pool close/send paths, aligned WebKit signal ID storage types, and marshalled OAuth parent refresh reloads back to the GTK main loop. | |||
| 37 | + | - **Persistence/update safety edges**: Enforced positive visit-count deltas in SQLite repos, removed redundant filter cache directory creation, canceled pending snapshot timer on `SetReady`, and made updater retry-loop exhaustion explicit. | |||
| 38 | + | - **Logging/dedup consistency**: Unified abrupt-session marker stat warnings under structured session logging and reduced favicon warning dedup growth by using a global cache-dir warning key plus callback coverage tests. | |||
| 39 | + | - **Omnibox input/ghost correctness**: Enforced minimum typed input before ghost suggestions, trimmed leading spaces before URL interpretation, deferred omnibox auto-open until layout is ready, and fixed width/layout edge-cases. | |||
| 40 | + | - **History/autocomplete edge-cases**: Normalized FTS queries containing slashes and capped `about:blank` visit-count inflation. | |||
| 41 | + | - **Navigation/history robustness**: Added pane history cleanup hooks, batched/capped visit increment fallback, and idempotent `NavigateUseCase.Close()`. | |||
| 42 | + | - **WebView lifecycle/reset correctness**: Reset pooled WebView transient state more thoroughly and improved callback/signal wiring consistency. | |||
| 43 | + | - **Navigation safety guards**: Added `CanGoBack` protection in navigation policy handling and corrected back-forward-list signal disconnection target. | |||
| 44 | + | - **Crash handling and retry edge-cases**: Hardened crash-page routing safety, updater retry semantics/documentation, and related infra error paths. | |||
| 45 | + | - **Filtering/snapshot/session reliability**: Fixed race/error-handling paths in filtering updates, snapshot persistence, and session startup marker processing. | |||
| 46 | + | - **WebView pool shutdown race**: Re-check `closed` flag after `createWebView` in `Acquire` to prevent leaking WebViews when pool closes during creation. | |||
| 47 | + | - **Updater transient error detection**: Broadened `isRetryableRequestError` to detect `ECONNRESET`, `ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, and `EADDRNOTAVAIL` syscall errors, plus `net.Error.Temporary()` and `url.Error` wrappers. | |||
| 48 | + | - **Session lock probe accuracy**: `markAbruptExits` now uses non-blocking flock instead of `os.Stat`, correctly distinguishing active sessions (lock held) from stale ones (lock acquirable) and skipping on permission/IO errors. | |||
| 49 | + | - **Crash page "Stay on this page" UX**: Button no longer navigates to `dumb://home/crash`; it now stays on the crash page as its label suggests. | |||
| 50 | + | - **Visit increment fallback overflow**: `IncrementVisitCountBy` now returns an error when delta exceeds the fallback cap instead of silently dropping increments. | |||
| 51 | + | ||||
| 52 | + | ### Security | |||
| 53 | + | ||||
| 54 | + | - **External scheme launch guard**: Restricted external URL scheme launching to user-gesture initiated navigations. | |||
| 55 | + | ||||
| 5 | ## [0.26.1] - 2026-01-25 | 56 | ## [0.26.1] - 2026-01-25 | ||
| 6 | 57 | ||||
| 7 | ### Fixed | 58 | ### Fixed | ||
M cmd/dumber/main.go +2 -0
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | "github.com/bnema/dumber/internal/infrastructure/xdg" | 28 | "github.com/bnema/dumber/internal/infrastructure/xdg" | ||
| 29 | "github.com/bnema/dumber/internal/logging" | 29 | "github.com/bnema/dumber/internal/logging" | ||
| 30 | "github.com/bnema/dumber/internal/ui" | 30 | "github.com/bnema/dumber/internal/ui" | ||
| 31 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 31 | "github.com/bnema/dumber/internal/ui/theme" | 32 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 32 | "github.com/rs/zerolog" | 33 | "github.com/rs/zerolog" | ||
| 33 | ) | 34 | ) | ||
| @@ -71,6 +72,7 @@ func main() { | |||||
| 71 | 72 | ||||
| 72 | func runGUI() int { | 73 | func runGUI() int { | ||
| 73 | runtime.LockOSThread() | 74 | runtime.LockOSThread() | ||
| 75 | + | component.SetSkeletonVersion(version) | |||
| 74 | timer := bootstrap.NewStartupTimer() | 76 | timer := bootstrap.NewStartupTimer() | ||
| 75 | 77 | ||||
| 76 | cfg := initConfig() | 78 | cfg := initConfig() | ||
M docs/config/index.md +16 -5
| @@ -432,12 +432,23 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal | |||||
| 432 | [media] | 432 | [media] | ||
| 433 | hardware_decoding = "auto" # HW preferred, SW fallback | 433 | hardware_decoding = "auto" # HW preferred, SW fallback | ||
| 434 | prefer_av1 = false # Let site choose codec | 434 | prefer_av1 = false # Let site choose codec | ||
| 435 | - | show_diagnostics = false # Enable for debugging | 435 | + | show_diagnostics = false # Keep off for daily use |
| 436 | force_vsync = false # Let compositor handle VSync | 436 | force_vsync = false # Let compositor handle VSync | ||
| 437 | gl_rendering_mode = "auto" # GStreamer picks best GL API | 437 | gl_rendering_mode = "auto" # GStreamer picks best GL API | ||
| 438 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | 438 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | ||
| 439 | ``` | 439 | ``` | ||
| 440 | 440 | ||||
| 441 | + | **Diagnostic mode toggle path:** | |||
| 442 | + | ||||
| 443 | + | ```toml | |||
| 444 | + | # ~/.config/dumber/config.toml | |||
| 445 | + | [media] | |||
| 446 | + | show_diagnostics = true | |||
| 447 | + | gstreamer_debug_level = 3 | |||
| 448 | + | ``` | |||
| 449 | + | ||||
| 450 | + | Set `show_diagnostics = false` and `gstreamer_debug_level = 0` to return to normal mode. | |||
| 451 | + | ||||
| 441 | **Diagnostics CLI:** | 452 | **Diagnostics CLI:** | ||
| 442 | ```bash | 453 | ```bash | ||
| 443 | # Check GStreamer plugins and VA-API status | 454 | # Check GStreamer plugins and VA-API status | ||
| @@ -529,7 +540,7 @@ Performance profiles provide preset configurations for WebKitGTK tuning. These s | |||||
| 529 | 540 | ||||
| 530 | | Profile | Description | Use Case | | 541 | | Profile | Description | Use Case | | ||
| 531 | |---------|-------------|----------| | 542 | |---------|-------------|----------| | ||
| 532 | - | | `default` | No tuning, uses WebKit defaults | Normal browsing, most users | | 543 | + | | `default` | No tuning, uses WebKit defaults | Compatibility baseline, troubleshooting | |
| 533 | | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving | | 544 | | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving | | ||
| 534 | | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems | | 545 | | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems | | ||
| 535 | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | 546 | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | ||
| @@ -549,10 +560,10 @@ Performance profiles provide preset configurations for WebKitGTK tuning. These s | |||||
| 549 | **Example:** | 560 | **Example:** | ||
| 550 | ```toml | 561 | ```toml | ||
| 551 | [performance] | 562 | [performance] | ||
| 552 | - | profile = "default" # Most users - no tuning needed | 563 | + | profile = "default" # WebKit baseline default |
| 553 | 564 | ||||
| 554 | - | # For low-RAM systems: | 565 | + | # For WebKit baseline behavior: |
| 555 | - | # profile = "lite" | 566 | + | # profile = "default" |
| 556 | 567 | ||||
| 557 | # For heavy pages (GitHub PRs, complex web apps): | 568 | # For heavy pages (GitHub PRs, complex web apps): | ||
| 558 | # profile = "max" | 569 | # profile = "max" | ||
M docs/reference/index.md +1 -0
| @@ -6,6 +6,7 @@ | |||||
| 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | ||
| 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | ||
| 8 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | 8 | - [Troubleshooting](./troubleshooting.md) - Common issues and solutions | ||
| 9 | + | - [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation | |||
| 9 | 10 | ||||
| 10 | ## Related | 11 | ## Related | ||
| 11 | 12 | ||||
A docs/reference/session-exit-classification-runbook.md +92 -0
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | # Session Exit Classification Runbook | |||
| 2 | + | ||||
| 3 | + | This runbook classifies browser sessions using existing P0 marker files in the log directory: | |||
| 4 | + | ||||
| 5 | + | - `session_<id>.startup.marker` | |||
| 6 | + | - `session_<id>.shutdown.marker` | |||
| 7 | + | - `session_<id>.abrupt.marker` | |||
| 8 | + | ||||
| 9 | + | Marker directory (default): | |||
| 10 | + | ||||
| 11 | + | ```bash | |||
| 12 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |||
| 13 | + | ``` | |||
| 14 | + | ||||
| 15 | + | ## Classification Model | |||
| 16 | + | ||||
| 17 | + | - `clean_exit` (`marker-confirmed`) | |||
| 18 | + | - `shutdown.marker` is present. | |||
| 19 | + | - `main_process_crash_or_abrupt` (`marker-confirmed`) | |||
| 20 | + | - `abrupt.marker` is present and no `shutdown.marker` exists. | |||
| 21 | + | - `external_kill_or_oom_inferred` (`best-effort`) | |||
| 22 | + | - `startup.marker` exists, but `shutdown.marker` and `abrupt.marker` are both missing. | |||
| 23 | + | - This is explicitly inferred, not confirmed. | |||
| 24 | + | ||||
| 25 | + | Implementation path in code: | |||
| 26 | + | ||||
| 27 | + | - `internal/bootstrap/session_exit_classification.go` | |||
| 28 | + | - `ClassifySessionExitFromMarkers(lockDir, sessionID)` | |||
| 29 | + | - `BuildSessionExitReport(lockDir)` | |||
| 30 | + | ||||
| 31 | + | ## Quick Marker Report (Shell) | |||
| 32 | + | ||||
| 33 | + | ```bash | |||
| 34 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |||
| 35 | + | ||||
| 36 | + | for startup in "$LOG_DIR"/session_*.startup.marker; do | |||
| 37 | + | [ -e "$startup" ] || continue | |||
| 38 | + | base="$(basename "$startup")" | |||
| 39 | + | sid="${base#session_}" | |||
| 40 | + | sid="${sid%.startup.marker}" | |||
| 41 | + | shutdown="$LOG_DIR/session_${sid}.shutdown.marker" | |||
| 42 | + | abrupt="$LOG_DIR/session_${sid}.abrupt.marker" | |||
| 43 | + | ||||
| 44 | + | if [ -f "$shutdown" ]; then | |||
| 45 | + | class="clean_exit" | |||
| 46 | + | inf="marker-confirmed" | |||
| 47 | + | elif [ -f "$abrupt" ]; then | |||
| 48 | + | class="main_process_crash_or_abrupt" | |||
| 49 | + | inf="marker-confirmed" | |||
| 50 | + | else | |||
| 51 | + | class="external_kill_or_oom_inferred" | |||
| 52 | + | inf="best-effort" | |||
| 53 | + | fi | |||
| 54 | + | ||||
| 55 | + | printf "%s\t%s\t%s\n" "$sid" "$class" "$inf" | |||
| 56 | + | done | sort | |||
| 57 | + | ``` | |||
| 58 | + | ||||
| 59 | + | ## Optional System-Log Correlation | |||
| 60 | + | ||||
| 61 | + | Use this only when `external_kill_or_oom_inferred` needs more evidence. | |||
| 62 | + | ||||
| 63 | + | 1. Get the startup timestamp for a session: | |||
| 64 | + | ||||
| 65 | + | ```bash | |||
| 66 | + | SID="<session-id>" | |||
| 67 | + | LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs" | |||
| 68 | + | cat "$LOG_DIR/session_${SID}.startup.marker" | |||
| 69 | + | ``` | |||
| 70 | + | ||||
| 71 | + | 2. Inspect user journal around that window: | |||
| 72 | + | ||||
| 73 | + | ```bash | |||
| 74 | + | journalctl --user --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "dumber|killed|oom|sigkill|signal" | |||
| 75 | + | ``` | |||
| 76 | + | ||||
| 77 | + | 3. Inspect kernel/system OOM evidence: | |||
| 78 | + | ||||
| 79 | + | ```bash | |||
| 80 | + | journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "oom|out of memory|killed process" | |||
| 81 | + | ``` | |||
| 82 | + | ||||
| 83 | + | 4. Optional coredump check: | |||
| 84 | + | ||||
| 85 | + | ```bash | |||
| 86 | + | coredumpctl list | rg -i "dumber" | |||
| 87 | + | ``` | |||
| 88 | + | ||||
| 89 | + | Interpretation guidance: | |||
| 90 | + | ||||
| 91 | + | - OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`. | |||
| 92 | + | - No OOM evidence does not prove a clean exit; keep inference label explicit. | |||
M docs/reference/troubleshooting.md +20 -2
| @@ -29,16 +29,34 @@ dumber doctor --media # GStreamer/VA-API only | |||||
| 29 | 29 | ||||
| 30 | **Solution:** | 30 | **Solution:** | ||
| 31 | 1. Run `dumber doctor --media` | 31 | 1. Run `dumber doctor --media` | ||
| 32 | - | 2. Check hardware decoding: | 32 | + | 2. Enable diagnostic mode for startup media warnings: |
| 33 | + | ||||
| 34 | + | ```toml | |||
| 35 | + | # ~/.config/dumber/config.toml | |||
| 36 | + | [media] | |||
| 37 | + | show_diagnostics = true | |||
| 38 | + | gstreamer_debug_level = 3 | |||
| 39 | + | ``` | |||
| 40 | + | ||||
| 41 | + | 3. Check hardware decoding: | |||
| 42 | + | ||||
| 33 | ```toml | 43 | ```toml | ||
| 34 | # ~/.config/dumber/config.toml | 44 | # ~/.config/dumber/config.toml | ||
| 35 | [media] | 45 | [media] | ||
| 36 | hardware_decoding = "auto" # Try "disable" if issues persist | 46 | hardware_decoding = "auto" # Try "disable" if issues persist | ||
| 37 | ``` | 47 | ``` | ||
| 38 | - | 3. Install VA-API drivers: | 48 | + | |
| 49 | + | 4. Install VA-API drivers: | |||
| 39 | - AMD: `libva-mesa-driver` | 50 | - AMD: `libva-mesa-driver` | ||
| 40 | - Intel: `intel-media-driver` | 51 | - Intel: `intel-media-driver` | ||
| 41 | - NVIDIA: `libva-nvidia-driver` | 52 | - NVIDIA: `libva-nvidia-driver` | ||
| 53 | + | 5. After diagnosing, disable diagnostic mode for quieter daily runs: | |||
| 54 | + | ||||
| 55 | + | ```toml | |||
| 56 | + | [media] | |||
| 57 | + | show_diagnostics = false | |||
| 58 | + | gstreamer_debug_level = 0 | |||
| 59 | + | ``` | |||
| 42 | 60 | ||||
| 43 | ### Screen flickering on Wayland | 61 | ### Screen flickering on Wayland | ||
| 44 | 62 | ||||
M go.mod +3 -3
| @@ -3,7 +3,7 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/puregotk-webkit v0.0.9 | 6 | + | github.com/bnema/puregotk-webkit v0.0.10 |
| 7 | github.com/charmbracelet/bubbles v0.21.1 | 7 | github.com/charmbracelet/bubbles v0.21.1 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| @@ -22,13 +22,13 @@ require ( | |||||
| 22 | gopkg.in/yaml.v3 v3.0.1 | 22 | gopkg.in/yaml.v3 v3.0.1 | ||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | - | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | 25 | + | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 |
| 26 | 26 | ||||
| 27 | require ( | 27 | require ( | ||
| 28 | github.com/atotto/clipboard v0.1.4 // indirect | 28 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 29 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 29 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 30 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | 30 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | ||
| 31 | - | github.com/charmbracelet/x/ansi v0.11.5 // indirect | 31 | + | github.com/charmbracelet/x/ansi v0.11.6 // indirect |
| 32 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | 32 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | ||
| 33 | github.com/charmbracelet/x/term v0.2.2 // indirect | 33 | github.com/charmbracelet/x/term v0.2.2 // indirect | ||
| 34 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | 34 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | ||
M go.sum +6 -6 generated file: collapsed
M internal/application/port/mocks/mocks.go +159 -0
| @@ -1281,6 +1281,165 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) | |||||
| 1281 | return _c | 1281 | return _c | ||
| 1282 | } | 1282 | } | ||
| 1283 | 1283 | ||||
| 1284 | + | // NewMockDownloadResponse creates a new instance of MockDownloadResponse. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1285 | + | // The first argument is typically a *testing.T value. | |||
| 1286 | + | func NewMockDownloadResponse(t interface { | |||
| 1287 | + | mock.TestingT | |||
| 1288 | + | Cleanup(func()) | |||
| 1289 | + | }) *MockDownloadResponse { | |||
| 1290 | + | mock := &MockDownloadResponse{} | |||
| 1291 | + | mock.Mock.Test(t) | |||
| 1292 | + | ||||
| 1293 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1294 | + | ||||
| 1295 | + | return mock | |||
| 1296 | + | } | |||
| 1297 | + | ||||
| 1298 | + | // MockDownloadResponse is an autogenerated mock type for the DownloadResponse type | |||
| 1299 | + | type MockDownloadResponse struct { | |||
| 1300 | + | mock.Mock | |||
| 1301 | + | } | |||
| 1302 | + | ||||
| 1303 | + | type MockDownloadResponse_Expecter struct { | |||
| 1304 | + | mock *mock.Mock | |||
| 1305 | + | } | |||
| 1306 | + | ||||
| 1307 | + | func (_m *MockDownloadResponse) EXPECT() *MockDownloadResponse_Expecter { | |||
| 1308 | + | return &MockDownloadResponse_Expecter{mock: &_m.Mock} | |||
| 1309 | + | } | |||
| 1310 | + | ||||
| 1311 | + | // GetMimeType provides a mock function for the type MockDownloadResponse | |||
| 1312 | + | func (_mock *MockDownloadResponse) GetMimeType() string { | |||
| 1313 | + | ret := _mock.Called() | |||
| 1314 | + | ||||
| 1315 | + | if len(ret) == 0 { | |||
| 1316 | + | panic("no return value specified for GetMimeType") | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1319 | + | var r0 string | |||
| 1320 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 1321 | + | r0 = returnFunc() | |||
| 1322 | + | } else { | |||
| 1323 | + | r0 = ret.Get(0).(string) | |||
| 1324 | + | } | |||
| 1325 | + | return r0 | |||
| 1326 | + | } | |||
| 1327 | + | ||||
| 1328 | + | // MockDownloadResponse_GetMimeType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMimeType' | |||
| 1329 | + | type MockDownloadResponse_GetMimeType_Call struct { | |||
| 1330 | + | *mock.Call | |||
| 1331 | + | } | |||
| 1332 | + | ||||
| 1333 | + | // GetMimeType is a helper method to define mock.On call | |||
| 1334 | + | func (_e *MockDownloadResponse_Expecter) GetMimeType() *MockDownloadResponse_GetMimeType_Call { | |||
| 1335 | + | return &MockDownloadResponse_GetMimeType_Call{Call: _e.mock.On("GetMimeType")} | |||
| 1336 | + | } | |||
| 1337 | + | ||||
| 1338 | + | func (_c *MockDownloadResponse_GetMimeType_Call) Run(run func()) *MockDownloadResponse_GetMimeType_Call { | |||
| 1339 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1340 | + | run() | |||
| 1341 | + | }) | |||
| 1342 | + | return _c | |||
| 1343 | + | } | |||
| 1344 | + | ||||
| 1345 | + | func (_c *MockDownloadResponse_GetMimeType_Call) Return(s string) *MockDownloadResponse_GetMimeType_Call { | |||
| 1346 | + | _c.Call.Return(s) | |||
| 1347 | + | return _c | |||
| 1348 | + | } | |||
| 1349 | + | ||||
| 1350 | + | func (_c *MockDownloadResponse_GetMimeType_Call) RunAndReturn(run func() string) *MockDownloadResponse_GetMimeType_Call { | |||
| 1351 | + | _c.Call.Return(run) | |||
| 1352 | + | return _c | |||
| 1353 | + | } | |||
| 1354 | + | ||||
| 1355 | + | // GetSuggestedFilename provides a mock function for the type MockDownloadResponse | |||
| 1356 | + | func (_mock *MockDownloadResponse) GetSuggestedFilename() string { | |||
| 1357 | + | ret := _mock.Called() | |||
| 1358 | + | ||||
| 1359 | + | if len(ret) == 0 { | |||
| 1360 | + | panic("no return value specified for GetSuggestedFilename") | |||
| 1361 | + | } | |||
| 1362 | + | ||||
| 1363 | + | var r0 string | |||
| 1364 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 1365 | + | r0 = returnFunc() | |||
| 1366 | + | } else { | |||
| 1367 | + | r0 = ret.Get(0).(string) | |||
| 1368 | + | } | |||
| 1369 | + | return r0 | |||
| 1370 | + | } | |||
| 1371 | + | ||||
| 1372 | + | // MockDownloadResponse_GetSuggestedFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSuggestedFilename' | |||
| 1373 | + | type MockDownloadResponse_GetSuggestedFilename_Call struct { | |||
| 1374 | + | *mock.Call | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1377 | + | // GetSuggestedFilename is a helper method to define mock.On call | |||
| 1378 | + | func (_e *MockDownloadResponse_Expecter) GetSuggestedFilename() *MockDownloadResponse_GetSuggestedFilename_Call { | |||
| 1379 | + | return &MockDownloadResponse_GetSuggestedFilename_Call{Call: _e.mock.On("GetSuggestedFilename")} | |||
| 1380 | + | } | |||
| 1381 | + | ||||
| 1382 | + | func (_c *MockDownloadResponse_GetSuggestedFilename_Call) Run(run func()) *MockDownloadResponse_GetSuggestedFilename_Call { | |||
| 1383 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1384 | + | run() | |||
| 1385 | + | }) | |||
| 1386 | + | return _c | |||
| 1387 | + | } | |||
| 1388 | + | ||||
| 1389 | + | func (_c *MockDownloadResponse_GetSuggestedFilename_Call) Return(s string) *MockDownloadResponse_GetSuggestedFilename_Call { | |||
| 1390 | + | _c.Call.Return(s) | |||
| 1391 | + | return _c | |||
| 1392 | + | } | |||
| 1393 | + | ||||
| 1394 | + | func (_c *MockDownloadResponse_GetSuggestedFilename_Call) RunAndReturn(run func() string) *MockDownloadResponse_GetSuggestedFilename_Call { | |||
| 1395 | + | _c.Call.Return(run) | |||
| 1396 | + | return _c | |||
| 1397 | + | } | |||
| 1398 | + | ||||
| 1399 | + | // GetUri provides a mock function for the type MockDownloadResponse | |||
| 1400 | + | func (_mock *MockDownloadResponse) GetUri() string { | |||
| 1401 | + | ret := _mock.Called() | |||
| 1402 | + | ||||
| 1403 | + | if len(ret) == 0 { | |||
| 1404 | + | panic("no return value specified for GetUri") | |||
| 1405 | + | } | |||
| 1406 | + | ||||
| 1407 | + | var r0 string | |||
| 1408 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |||
| 1409 | + | r0 = returnFunc() | |||
| 1410 | + | } else { | |||
| 1411 | + | r0 = ret.Get(0).(string) | |||
| 1412 | + | } | |||
| 1413 | + | return r0 | |||
| 1414 | + | } | |||
| 1415 | + | ||||
| 1416 | + | // MockDownloadResponse_GetUri_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUri' | |||
| 1417 | + | type MockDownloadResponse_GetUri_Call struct { | |||
| 1418 | + | *mock.Call | |||
| 1419 | + | } | |||
| 1420 | + | ||||
| 1421 | + | // GetUri is a helper method to define mock.On call | |||
| 1422 | + | func (_e *MockDownloadResponse_Expecter) GetUri() *MockDownloadResponse_GetUri_Call { | |||
| 1423 | + | return &MockDownloadResponse_GetUri_Call{Call: _e.mock.On("GetUri")} | |||
| 1424 | + | } | |||
| 1425 | + | ||||
| 1426 | + | func (_c *MockDownloadResponse_GetUri_Call) Run(run func()) *MockDownloadResponse_GetUri_Call { | |||
| 1427 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1428 | + | run() | |||
| 1429 | + | }) | |||
| 1430 | + | return _c | |||
| 1431 | + | } | |||
| 1432 | + | ||||
| 1433 | + | func (_c *MockDownloadResponse_GetUri_Call) Return(s string) *MockDownloadResponse_GetUri_Call { | |||
| 1434 | + | _c.Call.Return(s) | |||
| 1435 | + | return _c | |||
| 1436 | + | } | |||
| 1437 | + | ||||
| 1438 | + | func (_c *MockDownloadResponse_GetUri_Call) RunAndReturn(run func() string) *MockDownloadResponse_GetUri_Call { | |||
| 1439 | + | _c.Call.Return(run) | |||
| 1440 | + | return _c | |||
| 1441 | + | } | |||
| 1442 | + | ||||
| 1284 | // NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1443 | // NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1285 | // The first argument is typically a *testing.T value. | 1444 | // The first argument is typically a *testing.T value. | ||
| 1286 | func NewMockFontDetector(t interface { | 1445 | func NewMockFontDetector(t interface { | ||
M internal/application/port/updater.go +4 -0
| @@ -3,10 +3,14 @@ package port | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 11 | + | // ErrUpdateCheckTransient indicates a temporary update-check failure that should not be treated as actionable. | |||
| 12 | + | var ErrUpdateCheckTransient = errors.New("transient update check failure") | |||
| 13 | + | ||||
| 10 | // UpdateChecker checks for available updates from a remote source. | 14 | // UpdateChecker checks for available updates from a remote source. | ||
| 11 | type UpdateChecker interface { | 15 | type UpdateChecker interface { | ||
| 12 | // CheckForUpdate compares the current version with the latest available release. | 16 | // CheckForUpdate compares the current version with the latest available release. | ||
M internal/application/port/webkit_context.go +17 -0
| @@ -3,6 +3,15 @@ package port | |||||
| 3 | 3 | ||||
| 4 | import "context" | 4 | import "context" | ||
| 5 | 5 | ||||
| 6 | + | // WebKitCookiePolicy controls cookie acceptance behavior for NetworkSession. | |||
| 7 | + | type WebKitCookiePolicy string | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | WebKitCookiePolicyAlways WebKitCookiePolicy = "always" | |||
| 11 | + | WebKitCookiePolicyNoThirdParty WebKitCookiePolicy = "no_third_party" | |||
| 12 | + | WebKitCookiePolicyNever WebKitCookiePolicy = "never" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 6 | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | 15 | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | ||
| 7 | // Zero values mean "use WebKit defaults". | 16 | // Zero values mean "use WebKit defaults". | ||
| 8 | type MemoryPressureConfig struct { | 17 | type MemoryPressureConfig struct { | ||
| @@ -42,6 +51,14 @@ type WebKitContextOptions struct { | |||||
| 42 | // CacheDir is the directory for cache data. | 51 | // CacheDir is the directory for cache data. | ||
| 43 | CacheDir string | 52 | CacheDir string | ||
| 44 | 53 | ||||
| 54 | + | // CookiePolicy controls cookie acceptance behavior. | |||
| 55 | + | // Empty value means runtime defaults are used. | |||
| 56 | + | CookiePolicy WebKitCookiePolicy | |||
| 57 | + | ||||
| 58 | + | // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession. | |||
| 59 | + | // False means ITP remains disabled unless enabled explicitly. | |||
| 60 | + | ITPEnabled bool | |||
| 61 | + | ||||
| 45 | // WebProcessMemory configures memory pressure for web processes. | 62 | // WebProcessMemory configures memory pressure for web processes. | ||
| 46 | // nil means use WebKit defaults. | 63 | // nil means use WebKit defaults. | ||
| 47 | WebProcessMemory *MemoryPressureConfig | 64 | WebProcessMemory *MemoryPressureConfig | ||
M internal/application/port/webview.go +16 -4
| @@ -52,6 +52,16 @@ type WebViewState struct { | |||||
| 52 | ZoomLevel float64 | 52 | ZoomLevel float64 | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | + | // WebProcessTerminationReason describes why the web process terminated. | |||
| 56 | + | type WebProcessTerminationReason int | |||
| 57 | + | ||||
| 58 | + | const ( | |||
| 59 | + | WebProcessTerminationUnknown WebProcessTerminationReason = iota | |||
| 60 | + | WebProcessTerminationCrashed | |||
| 61 | + | WebProcessTerminationExceededMemory | |||
| 62 | + | WebProcessTerminationByAPI | |||
| 63 | + | ) | |||
| 64 | + | ||||
| 55 | // PopupRequest contains metadata about a popup window request. | 65 | // PopupRequest contains metadata about a popup window request. | ||
| 56 | type PopupRequest struct { | 66 | type PopupRequest struct { | ||
| 57 | TargetURI string | 67 | TargetURI string | ||
| @@ -92,6 +102,8 @@ type WebViewCallbacks struct { | |||||
| 92 | // OnLinkHover is called when hovering over a link, image, or media element. | 102 | // OnLinkHover is called when hovering over a link, image, or media element. | ||
| 93 | // The uri parameter contains the target URL, or empty string when leaving. | 103 | // The uri parameter contains the target URL, or empty string when leaving. | ||
| 94 | OnLinkHover func(uri string) | 104 | OnLinkHover func(uri string) | ||
| 105 | + | // OnWebProcessTerminated is called when the web process exits unexpectedly. | |||
| 106 | + | OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string) | |||
| 95 | } | 107 | } | ||
| 96 | 108 | ||||
| 97 | // FindOptions configures search behavior. | 109 | // FindOptions configures search behavior. | ||
| @@ -111,10 +123,10 @@ type FindController interface { | |||||
| 111 | GetSearchText() string | 123 | GetSearchText() string | ||
| 112 | 124 | ||||
| 113 | // Signal connections | 125 | // Signal connections | ||
| 114 | - | OnFoundText(callback func(matchCount uint)) uint32 | 126 | + | OnFoundText(callback func(matchCount uint)) uint |
| 115 | - | OnFailedToFindText(callback func()) uint32 | 127 | + | OnFailedToFindText(callback func()) uint |
| 116 | - | OnCountedMatches(callback func(matchCount uint)) uint32 | 128 | + | OnCountedMatches(callback func(matchCount uint)) uint |
| 117 | - | DisconnectSignal(id uint32) | 129 | + | DisconnectSignal(id uint) |
| 118 | } | 130 | } | ||
| 119 | 131 | ||||
| 120 | // WebView defines the port interface for browser view operations. | 132 | // WebView defines the port interface for browser view operations. | ||
M internal/application/usecase/autocomplete.go +74 -41
| @@ -157,22 +157,21 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input | |||||
| 157 | return nil | 157 | return nil | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | - | for _, fav := range favorites { | 160 | + | return bestSuggestionFromURLs(input, autocomplete.SourceFavorite, |
| 161 | - | if fav == nil || fav.URL == "" { | 161 | + | len(favorites), |
| 162 | - | continue | 162 | + | func(i int) string { |
| 163 | - | } | 163 | + | if favorites[i] == nil { |
| 164 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL) | 164 | + | return "" |
| 165 | - | if ok { | |||
| 166 | - | return &autocomplete.Suggestion{ | |||
| 167 | - | FullText: matchedURL, | |||
| 168 | - | Suffix: suffix, | |||
| 169 | - | Source: autocomplete.SourceFavorite, | |||
| 170 | - | Title: fav.Title, | |||
| 171 | } | 165 | } | ||
| 172 | - | } | 166 | + | return favorites[i].URL |
| 173 | - | } | 167 | + | }, |
| 174 | - | 168 | + | func(i int) string { | |
| 175 | - | return nil | 169 | + | if favorites[i] == nil { |
| 170 | + | return "" | |||
| 171 | + | } | |||
| 172 | + | return favorites[i].Title | |||
| 173 | + | }, | |||
| 174 | + | ) | |||
| 176 | } | 175 | } | ||
| 177 | 176 | ||||
| 178 | // findHistorySuggestion finds the best matching history entry for the input. | 177 | // findHistorySuggestion finds the best matching history entry for the input. | ||
| @@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input | |||||
| 189 | return nil | 188 | return nil | ||
| 190 | } | 189 | } | ||
| 191 | 190 | ||||
| 192 | - | for _, entry := range entries { | 191 | + | return bestSuggestionFromURLs(input, autocomplete.SourceHistory, |
| 193 | - | if entry == nil || entry.URL == "" { | 192 | + | len(entries), |
| 194 | - | continue | 193 | + | func(i int) string { |
| 195 | - | } | 194 | + | if entries[i] == nil { |
| 196 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL) | 195 | + | return "" |
| 197 | - | if ok { | |||
| 198 | - | return &autocomplete.Suggestion{ | |||
| 199 | - | FullText: matchedURL, | |||
| 200 | - | Suffix: suffix, | |||
| 201 | - | Source: autocomplete.SourceHistory, | |||
| 202 | - | Title: entry.Title, | |||
| 203 | } | 196 | } | ||
| 204 | - | } | 197 | + | return entries[i].URL |
| 205 | - | } | 198 | + | }, |
| 206 | - | 199 | + | func(i int) string { | |
| 207 | - | return nil | 200 | + | if entries[i] == nil { |
| 201 | + | return "" | |||
| 202 | + | } | |||
| 203 | + | return entries[i].Title | |||
| 204 | + | }, | |||
| 205 | + | ) | |||
| 208 | } | 206 | } | ||
| 209 | 207 | ||||
| 210 | // findBangSuggestion finds the best matching bang shortcut for the input. | 208 | // findBangSuggestion finds the best matching bang shortcut for the input. | ||
| @@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str | |||||
| 239 | } | 237 | } | ||
| 240 | 238 | ||||
| 241 | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | 239 | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | ||
| 242 | - | for _, u := range urls { | 240 | + | suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls) |
| 243 | - | if u == "" { | 241 | + | if ok { |
| 244 | - | continue | 242 | + | return &autocomplete.Suggestion{ |
| 243 | + | FullText: matchedURL, | |||
| 244 | + | Suffix: suffix, | |||
| 245 | + | Source: source, | |||
| 245 | } | 246 | } | ||
| 246 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u) | 247 | + | } |
| 247 | - | if ok { | 248 | + | return nil |
| 248 | - | return &autocomplete.Suggestion{ | 249 | + | } |
| 249 | - | FullText: matchedURL, | 250 | + | |
| 250 | - | Suffix: suffix, | 251 | + | func bestSuggestionFromURLs( |
| 251 | - | Source: source, | 252 | + | input string, |
| 252 | - | } | 253 | + | source autocomplete.SuggestionSource, |
| 254 | + | count int, | |||
| 255 | + | urlAt func(int) string, | |||
| 256 | + | titleAt func(int) string, | |||
| 257 | + | ) *autocomplete.Suggestion { | |||
| 258 | + | if count <= 0 { | |||
| 259 | + | return nil | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | urls := make([]string, 0, count) | |||
| 263 | + | titles := make([]string, 0, count) | |||
| 264 | + | for i := 0; i < count; i++ { | |||
| 265 | + | url := urlAt(i) | |||
| 266 | + | if url == "" { | |||
| 267 | + | continue | |||
| 253 | } | 268 | } | ||
| 269 | + | urls = append(urls, url) | |||
| 270 | + | titles = append(titles, titleAt(i)) | |||
| 271 | + | } | |||
| 272 | + | if len(urls) == 0 { | |||
| 273 | + | return nil | |||
| 254 | } | 274 | } | ||
| 255 | 275 | ||||
| 256 | - | return nil | 276 | + | suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls) |
| 277 | + | if !ok { | |||
| 278 | + | return nil | |||
| 279 | + | } | |||
| 280 | + | title := "" | |||
| 281 | + | if idx >= 0 && idx < len(titles) { | |||
| 282 | + | title = titles[idx] | |||
| 283 | + | } | |||
| 284 | + | return &autocomplete.Suggestion{ | |||
| 285 | + | FullText: matchedURL, | |||
| 286 | + | Suffix: suffix, | |||
| 287 | + | Source: source, | |||
| 288 | + | Title: title, | |||
| 289 | + | } | |||
| 257 | } | 290 | } | ||
| 258 | 291 | ||||
| 259 | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | 292 | func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) { | ||
M internal/application/usecase/autocomplete_test.go +21 -2
| @@ -55,8 +55,8 @@ func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) { | |||||
| 55 | assert.True(t, output.Found) | 55 | assert.True(t, output.Found) | ||
| 56 | assert.NotNil(t, output.Suggestion) | 56 | assert.NotNil(t, output.Suggestion) | ||
| 57 | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | 57 | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | ||
| 58 | - | assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText) | 58 | + | assert.Equal(t, "github.com", output.Suggestion.FullText) |
| 59 | - | assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix) | 59 | + | assert.Equal(t, ".com", output.Suggestion.Suffix) |
| 60 | assert.Equal(t, "My Repo", output.Suggestion.Title) | 60 | assert.Equal(t, "My Repo", output.Suggestion.Title) | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| @@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) { | |||||
| 167 | assert.Equal(t, "a.dev", suggestion.Suffix) | 167 | assert.Equal(t, "a.dev", suggestion.Suffix) | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | + | func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) { | |||
| 171 | + | ctx := testContext() | |||
| 172 | + | uc := usecase.NewAutocompleteUseCase(nil, nil, nil) | |||
| 173 | + | ||||
| 174 | + | urls := []string{ | |||
| 175 | + | "https://www.google.com/url?q=https://dashboard.stripe.com", | |||
| 176 | + | "https://google.com", | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{ | |||
| 180 | + | VisibleURLs: urls, | |||
| 181 | + | AllowBangs: false, | |||
| 182 | + | }) | |||
| 183 | + | ||||
| 184 | + | require.NotNil(t, suggestion) | |||
| 185 | + | assert.Equal(t, "google.com", suggestion.FullText) | |||
| 186 | + | assert.Equal(t, "gle.com", suggestion.Suffix) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 170 | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | 189 | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | ||
| 171 | ctx := testContext() | 190 | ctx := testContext() | ||
| 172 | 191 | ||||
M internal/application/usecase/check_update.go +10 -0
| @@ -2,6 +2,7 @@ package usecase | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/domain/build" | 8 | "github.com/bnema/dumber/internal/domain/build" | ||
| @@ -53,6 +54,15 @@ func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) ( | |||||
| 53 | 54 | ||||
| 54 | info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version) | 55 | info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version) | ||
| 55 | if err != nil { | 56 | if err != nil { | ||
| 57 | + | if errors.Is(err, port.ErrUpdateCheckTransient) { | |||
| 58 | + | log.Debug().Err(err).Msg("transient update check failure") | |||
| 59 | + | return &CheckUpdateOutput{ | |||
| 60 | + | UpdateAvailable: false, | |||
| 61 | + | CanAutoUpdate: false, | |||
| 62 | + | CurrentVersion: uc.buildInfo.Version, | |||
| 63 | + | LatestVersion: uc.buildInfo.Version, | |||
| 64 | + | }, nil | |||
| 65 | + | } | |||
| 56 | log.Warn().Err(err).Msg("update check failed") | 66 | log.Warn().Err(err).Msg("update check failed") | ||
| 57 | return nil, err | 67 | return nil, err | ||
| 58 | } | 68 | } | ||
A internal/application/usecase/check_update_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/build" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCheckUpdateUseCase_TransientErrorReturnsNoUpdate(t *testing.T) { | |||
| 14 | + | uc := NewCheckUpdateUseCase( | |||
| 15 | + | stubUpdateChecker{err: port.ErrUpdateCheckTransient}, | |||
| 16 | + | stubUpdateApplier{}, | |||
| 17 | + | build.Info{Version: "1.2.3"}, | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | out, err := uc.Execute(context.Background(), CheckUpdateInput{}) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatalf("Execute() unexpected error: %v", err) | |||
| 23 | + | } | |||
| 24 | + | if out.UpdateAvailable { | |||
| 25 | + | t.Fatal("UpdateAvailable = true, want false") | |||
| 26 | + | } | |||
| 27 | + | if out.CurrentVersion != "1.2.3" || out.LatestVersion != "1.2.3" { | |||
| 28 | + | t.Fatalf("unexpected versions: current=%q latest=%q", out.CurrentVersion, out.LatestVersion) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestCheckUpdateUseCase_NonTransientErrorPropagates(t *testing.T) { | |||
| 33 | + | uc := NewCheckUpdateUseCase( | |||
| 34 | + | stubUpdateChecker{err: errors.New("boom")}, | |||
| 35 | + | stubUpdateApplier{}, | |||
| 36 | + | build.Info{Version: "1.2.3"}, | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | _, err := uc.Execute(context.Background(), CheckUpdateInput{}) | |||
| 40 | + | if err == nil { | |||
| 41 | + | t.Fatal("expected error, got nil") | |||
| 42 | + | } | |||
| 43 | + | if err.Error() != "boom" { | |||
| 44 | + | t.Fatalf("unexpected error: %v", err) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type stubUpdateChecker struct { | |||
| 49 | + | info *entity.UpdateInfo | |||
| 50 | + | err error | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (s stubUpdateChecker) CheckForUpdate(context.Context, string) (*entity.UpdateInfo, error) { | |||
| 54 | + | if s.err != nil { | |||
| 55 | + | return nil, s.err | |||
| 56 | + | } | |||
| 57 | + | return s.info, nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | type stubUpdateApplier struct{} | |||
| 61 | + | ||||
| 62 | + | func (stubUpdateApplier) CanSelfUpdate(context.Context) bool { | |||
| 63 | + | return false | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (stubUpdateApplier) SelfUpdateBlockedReason(context.Context) port.SelfUpdateBlockedReason { | |||
| 67 | + | return port.SelfUpdateBlockedNotWritable | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (stubUpdateApplier) GetBinaryPath() (string, error) { | |||
| 71 | + | return "", nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (stubUpdateApplier) StageUpdate(context.Context, string) error { | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (stubUpdateApplier) HasStagedUpdate(context.Context) bool { | |||
| 79 | + | return false | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (stubUpdateApplier) ApplyOnExit(context.Context) (string, error) { | |||
| 83 | + | return "", nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (stubUpdateApplier) ClearStagedUpdate(context.Context) error { | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
M internal/application/usecase/find_in_page.go +1 -1
| @@ -43,7 +43,7 @@ type FindInPageUseCase struct { | |||||
| 43 | ctx context.Context | 43 | ctx context.Context | ||
| 44 | 44 | ||||
| 45 | controller port.FindController | 45 | controller port.FindController | ||
| 46 | - | signalIDs []uint32 | 46 | + | signalIDs []uint |
| 47 | 47 | ||||
| 48 | query string | 48 | query string | ||
| 49 | matchCount uint | 49 | matchCount uint | ||
M internal/application/usecase/navigate.go +233 -52
| @@ -3,6 +3,7 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net/url" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -20,12 +21,32 @@ const ( | |||||
| 20 | 21 | ||||
| 21 | // logURLMaxLen is the max length for URLs in log messages. | 22 | // logURLMaxLen is the max length for URLs in log messages. | ||
| 22 | logURLMaxLen = 60 | 23 | logURLMaxLen = 60 | ||
| 24 | + | ||||
| 25 | + | // historyWorkerFlushInterval coalesces bursts into fewer persistence writes. | |||
| 26 | + | historyWorkerFlushInterval = 100 * time.Millisecond | |||
| 27 | + | ||||
| 28 | + | // historyFallbackIncrementCap bounds DB writes when delta-increment API is unavailable. | |||
| 29 | + | historyFallbackIncrementCap = 256 | |||
| 23 | ) | 30 | ) | ||
| 24 | 31 | ||||
| 25 | // historyRecord holds data for async history recording. | 32 | // historyRecord holds data for async history recording. | ||
| 26 | type historyRecord struct { | 33 | type historyRecord struct { | ||
| 27 | - | url string | 34 | + | url string |
| 28 | - | timestamp time.Time | 35 | + | visits int |
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type historyVisitDeltaIncrementer interface { | |||
| 39 | + | IncrementVisitCountBy(ctx context.Context, url string, delta int) error | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type paneHistoryState struct { | |||
| 43 | + | lastRawURL string | |||
| 44 | + | lastCanonicalURL string | |||
| 45 | + | lastRecordedAt time.Time | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type historyCanonicalizationOptions struct { | |||
| 49 | + | StripTrackingParams bool | |||
| 29 | } | 50 | } | ||
| 30 | 51 | ||||
| 31 | // NavigateUseCase handles URL navigation with history recording and zoom application. | 52 | // NavigateUseCase handles URL navigation with history recording and zoom application. | ||
| @@ -33,7 +54,9 @@ type NavigateUseCase struct { | |||||
| 33 | historyRepo repository.HistoryRepository | 54 | historyRepo repository.HistoryRepository | ||
| 34 | zoomRepo repository.ZoomRepository | 55 | zoomRepo repository.ZoomRepository | ||
| 35 | defaultZoom float64 | 56 | defaultZoom float64 | ||
| 36 | - | recentVisits sync.Map // key: url, value: time.Time - for deduplication | 57 | + | recentMu sync.Mutex |
| 58 | + | recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state | |||
| 59 | + | closeOnce sync.Once | |||
| 37 | 60 | ||||
| 38 | // Async history recording | 61 | // Async history recording | ||
| 39 | historyQueue chan historyRecord | 62 | historyQueue chan historyRecord | ||
| @@ -57,6 +80,7 @@ func NewNavigateUseCase( | |||||
| 57 | historyRepo: historyRepo, | 80 | historyRepo: historyRepo, | ||
| 58 | zoomRepo: zoomRepo, | 81 | zoomRepo: zoomRepo, | ||
| 59 | defaultZoom: defaultZoom, | 82 | defaultZoom: defaultZoom, | ||
| 83 | + | recentVisits: make(map[string]paneHistoryState), | |||
| 60 | historyQueue: make(chan historyRecord, historyQueueSize), | 84 | historyQueue: make(chan historyRecord, historyQueueSize), | ||
| 61 | done: make(chan struct{}), | 85 | done: make(chan struct{}), | ||
| 62 | ctx: context.Background(), | 86 | ctx: context.Background(), | ||
| @@ -72,7 +96,9 @@ func NewNavigateUseCase( | |||||
| 72 | // Close shuts down the background history worker and drains any pending records. | 96 | // Close shuts down the background history worker and drains any pending records. | ||
| 73 | // This should be called when the application is shutting down. | 97 | // This should be called when the application is shutting down. | ||
| 74 | func (uc *NavigateUseCase) Close() { | 98 | func (uc *NavigateUseCase) Close() { | ||
| 75 | - | close(uc.done) | 99 | + | uc.closeOnce.Do(func() { |
| 100 | + | close(uc.done) | |||
| 101 | + | }) | |||
| 76 | uc.wg.Wait() | 102 | uc.wg.Wait() | ||
| 77 | } | 103 | } | ||
| 78 | 104 | ||||
| @@ -117,36 +143,74 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||||
| 117 | // This prevents inflation from redirects and rapid navigation. | 143 | // This prevents inflation from redirects and rapid navigation. | ||
| 118 | const historyDeduplicationWindow = 2 * time.Second | 144 | const historyDeduplicationWindow = 2 * time.Second | ||
| 119 | 145 | ||||
| 146 | + | // stripTrackingParamsForHistoryDedup controls whether known tracking parameters | |||
| 147 | + | // are removed while canonicalizing URLs for history deduplication/storage. | |||
| 148 | + | const stripTrackingParamsForHistoryDedup = true | |||
| 149 | + | ||||
| 120 | // RecordHistory queues a history entry for async recording. | 150 | // RecordHistory queues a history entry for async recording. | ||
| 121 | // This is non-blocking to avoid SQLite I/O on the GTK main thread. | 151 | // This is non-blocking to avoid SQLite I/O on the GTK main thread. | ||
| 122 | // Should be called on LoadCommitted when URI is guaranteed correct. | 152 | // Should be called on LoadCommitted when URI is guaranteed correct. | ||
| 123 | - | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | 153 | + | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) { |
| 124 | log := logging.FromContext(ctx) | 154 | log := logging.FromContext(ctx) | ||
| 155 | + | rawURL = strings.TrimSpace(rawURL) | |||
| 156 | + | if rawURL == "" { | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 125 | 159 | ||||
| 126 | - | // Normalize URL to avoid duplicates (e.g., github.com vs github.com/) | 160 | + | canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{ |
| 127 | - | url = normalizeURLForHistory(url) | 161 | + | StripTrackingParams: stripTrackingParamsForHistoryDedup, |
| 162 | + | }) | |||
| 163 | + | if canonicalURL == "" { | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | paneID = normalizePaneID(paneID) | |||
| 128 | 168 | ||||
| 129 | - | // Time-based deduplication: skip if same URL was recorded recently | |||
| 130 | - | // This check is fast (sync.Map lookup) and stays on the main thread | |||
| 131 | now := time.Now() | 169 | now := time.Now() | ||
| 132 | - | if lastTime, ok := uc.recentVisits.Load(url); ok { | 170 | + | |
| 133 | - | if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow { | 171 | + | uc.recentMu.Lock() |
| 134 | - | log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window") | 172 | + | state := uc.recentVisits[paneID] |
| 135 | - | return | 173 | + | |
| 136 | - | } | 174 | + | // Ignore in-page hash transitions for history persistence. |
| 175 | + | if isHashOnlyTransition(state.lastRawURL, rawURL) { | |||
| 176 | + | state.lastRawURL = rawURL | |||
| 177 | + | uc.recentVisits[paneID] = state | |||
| 178 | + | uc.recentMu.Unlock() | |||
| 179 | + | return | |||
| 137 | } | 180 | } | ||
| 138 | - | uc.recentVisits.Store(url, now) | 181 | + | |
| 182 | + | // Per-pane short-window deduplication by canonical URL. | |||
| 183 | + | if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow { | |||
| 184 | + | state.lastRawURL = rawURL | |||
| 185 | + | uc.recentVisits[paneID] = state | |||
| 186 | + | uc.recentMu.Unlock() | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | state.lastRawURL = rawURL | |||
| 191 | + | state.lastCanonicalURL = canonicalURL | |||
| 192 | + | state.lastRecordedAt = now | |||
| 193 | + | uc.recentVisits[paneID] = state | |||
| 194 | + | uc.recentMu.Unlock() | |||
| 139 | 195 | ||||
| 140 | // Non-blocking send to async queue | 196 | // Non-blocking send to async queue | ||
| 141 | select { | 197 | select { | ||
| 142 | - | case uc.historyQueue <- historyRecord{url: url, timestamp: now}: | 198 | + | case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}: |
| 143 | - | log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history record queued") | |||
| 144 | default: | 199 | default: | ||
| 145 | // Queue full - log warning but don't block navigation | 200 | // Queue full - log warning but don't block navigation | ||
| 146 | - | log.Warn().Str("url", url).Msg("history queue full, dropping record") | 201 | + | log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record") |
| 147 | } | 202 | } | ||
| 148 | } | 203 | } | ||
| 149 | 204 | ||||
| 205 | + | // ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth. | |||
| 206 | + | // Callers should invoke this when a pane is closed. | |||
| 207 | + | func (uc *NavigateUseCase) ClearPaneHistory(paneID string) { | |||
| 208 | + | paneID = normalizePaneID(paneID) | |||
| 209 | + | uc.recentMu.Lock() | |||
| 210 | + | delete(uc.recentVisits, paneID) | |||
| 211 | + | uc.recentMu.Unlock() | |||
| 212 | + | } | |||
| 213 | + | ||||
| 150 | // historyWorker is a background goroutine that processes history records. | 214 | // historyWorker is a background goroutine that processes history records. | ||
| 151 | // It drains the queue and persists records to the database without blocking the UI. | 215 | // It drains the queue and persists records to the database without blocking the UI. | ||
| 152 | func (uc *NavigateUseCase) historyWorker() { | 216 | func (uc *NavigateUseCase) historyWorker() { | ||
| @@ -156,22 +220,44 @@ func (uc *NavigateUseCase) historyWorker() { | |||||
| 156 | Str("component", "history-worker"). | 220 | Str("component", "history-worker"). | ||
| 157 | Logger() | 221 | Logger() | ||
| 158 | 222 | ||||
| 223 | + | ticker := time.NewTicker(historyWorkerFlushInterval) | |||
| 224 | + | defer ticker.Stop() | |||
| 225 | + | ||||
| 226 | + | pending := make(map[string]int) | |||
| 227 | + | ||||
| 228 | + | flushPending := func() { | |||
| 229 | + | if len(pending) == 0 { | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | for historyURL, visits := range pending { | |||
| 233 | + | uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits}) | |||
| 234 | + | } | |||
| 235 | + | clear(pending) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | drainQueue := func() { | |||
| 239 | + | for { | |||
| 240 | + | select { | |||
| 241 | + | case record := <-uc.historyQueue: | |||
| 242 | + | pending[record.url] += record.visits | |||
| 243 | + | default: | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 159 | for { | 249 | for { | ||
| 160 | select { | 250 | select { | ||
| 161 | case record := <-uc.historyQueue: | 251 | case record := <-uc.historyQueue: | ||
| 162 | - | uc.persistHistory(uc.ctx, record) | 252 | + | pending[record.url] += record.visits |
| 253 | + | case <-ticker.C: | |||
| 254 | + | flushPending() | |||
| 163 | case <-uc.done: | 255 | case <-uc.done: | ||
| 164 | - | // Drain remaining records before shutdown | |||
| 165 | log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue") | 256 | log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue") | ||
| 166 | - | for { | 257 | + | drainQueue() |
| 167 | - | select { | 258 | + | flushPending() |
| 168 | - | case record := <-uc.historyQueue: | 259 | + | log.Debug().Msg("history worker shutdown complete") |
| 169 | - | uc.persistHistory(uc.ctx, record) | 260 | + | return |
| 170 | - | default: | |||
| 171 | - | log.Debug().Msg("history worker shutdown complete") | |||
| 172 | - | return | |||
| 173 | - | } | |||
| 174 | - | } | |||
| 175 | } | 261 | } | ||
| 176 | } | 262 | } | ||
| 177 | } | 263 | } | ||
| @@ -189,13 +275,33 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 189 | } | 275 | } | ||
| 190 | 276 | ||||
| 191 | if existing != nil { | 277 | if existing != nil { | ||
| 192 | - | // Increment visit count | 278 | + | delta := max(1, record.visits) |
| 193 | - | if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil { | 279 | + | if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok { |
| 194 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count") | 280 | + | if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil { |
| 281 | + | log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta") | |||
| 282 | + | return | |||
| 283 | + | } | |||
| 284 | + | return | |||
| 285 | + | } | |||
| 286 | + | iterations := delta | |||
| 287 | + | if iterations > historyFallbackIncrementCap { | |||
| 288 | + | log.Warn(). | |||
| 289 | + | Str("url", record.url). | |||
| 290 | + | Int("delta", delta). | |||
| 291 | + | Int("cap", historyFallbackIncrementCap). | |||
| 292 | + | Msg("visit count fallback increment capped") | |||
| 293 | + | iterations = historyFallbackIncrementCap | |||
| 294 | + | } | |||
| 295 | + | for i := 0; i < iterations; i++ { | |||
| 296 | + | if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil { | |||
| 297 | + | log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count") | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 195 | } | 300 | } | ||
| 196 | } else { | 301 | } else { | ||
| 197 | // Create new entry | 302 | // Create new entry | ||
| 198 | entry := entity.NewHistoryEntry(record.url, "") | 303 | entry := entity.NewHistoryEntry(record.url, "") | ||
| 304 | + | entry.VisitCount = int64(max(1, record.visits)) | |||
| 199 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | 305 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | ||
| 200 | log.Warn().Err(err).Str("url", record.url).Msg("failed to save history") | 306 | log.Warn().Err(err).Str("url", record.url).Msg("failed to save history") | ||
| 201 | } | 307 | } | ||
| @@ -203,14 +309,20 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||||
| 203 | } | 309 | } | ||
| 204 | 310 | ||||
| 205 | // UpdateHistoryTitle updates the title of a history entry after page load. | 311 | // UpdateHistoryTitle updates the title of a history entry after page load. | ||
| 206 | - | func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error { | 312 | + | func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error { |
| 207 | log := logging.FromContext(ctx) | 313 | log := logging.FromContext(ctx) | ||
| 208 | 314 | ||||
| 209 | - | // Normalize URL to match storage (e.g., github.com/ -> github.com) | 315 | + | // Canonicalize URL the same way history records are persisted. |
| 210 | - | url = normalizeURLForHistory(url) | 316 | + | historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{ |
| 211 | - | log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title") | 317 | + | StripTrackingParams: stripTrackingParamsForHistoryDedup, |
| 318 | + | }) | |||
| 319 | + | if historyURL == "" { | |||
| 320 | + | log.Debug().Str("title", title).Msg("history URL empty after canonicalization, skipping title update") | |||
| 321 | + | return nil | |||
| 322 | + | } | |||
| 323 | + | log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title") | |||
| 212 | 324 | ||||
| 213 | - | entry, err := uc.historyRepo.FindByURL(ctx, url) | 325 | + | entry, err := uc.historyRepo.FindByURL(ctx, historyURL) |
| 214 | if err != nil { | 326 | if err != nil { | ||
| 215 | return fmt.Errorf("failed to find history entry: %w", err) | 327 | return fmt.Errorf("failed to find history entry: %w", err) | ||
| 216 | } | 328 | } | ||
| @@ -219,7 +331,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st | |||||
| 219 | // Entry doesn't exist - don't create it here | 331 | // Entry doesn't exist - don't create it here | ||
| 220 | // Initial navigation should have already created the entry | 332 | // Initial navigation should have already created the entry | ||
| 221 | // This can happen if URL changed during page load (SPA, redirect) | 333 | // This can happen if URL changed during page load (SPA, redirect) | ||
| 222 | - | log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update") | 334 | + | log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update") |
| 223 | return nil | 335 | return nil | ||
| 224 | } | 336 | } | ||
| 225 | 337 | ||||
| @@ -250,20 +362,89 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error | |||||
| 250 | return webview.Stop(ctx) | 362 | return webview.Stop(ctx) | ||
| 251 | } | 363 | } | ||
| 252 | 364 | ||||
| 253 | - | // normalizeURLForHistory normalizes a URL for history storage. | 365 | + | func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string { |
| 254 | - | // Strips trailing slash to avoid duplicates like github.com vs github.com/ | 366 | + | raw = strings.TrimSpace(raw) |
| 255 | - | func normalizeURLForHistory(url string) string { | 367 | + | if raw == "" { |
| 256 | - | // Don't strip if URL is just the protocol + domain (e.g., "https://github.com") | 368 | + | return "" |
| 257 | - | // Only strip trailing slash from paths | 369 | + | } |
| 258 | - | if strings.HasSuffix(url, "/") { | 370 | + | |
| 259 | - | // Check if it's just domain with trailing slash (e.g., "https://github.com/") | 371 | + | parsed, err := url.Parse(raw) |
| 260 | - | // by counting slashes - protocol has 2, domain adds 0, path adds more | 372 | + | if err != nil { |
| 261 | - | if strings.Count(url, "/") == 3 { | 373 | + | return strings.TrimSuffix(raw, "/") |
| 262 | - | // It's "https://domain.com/" - strip the trailing slash | 374 | + | } |
| 263 | - | return strings.TrimSuffix(url, "/") | 375 | + | |
| 376 | + | parsed.Scheme = strings.ToLower(parsed.Scheme) | |||
| 377 | + | parsed.Host = strings.ToLower(parsed.Host) | |||
| 378 | + | parsed.Fragment = "" | |||
| 379 | + | parsed.Path = normalizePathForHistory(parsed.Path) | |||
| 380 | + | ||||
| 381 | + | query := parsed.Query() | |||
| 382 | + | if opts.StripTrackingParams { | |||
| 383 | + | for key := range query { | |||
| 384 | + | if isTrackingQueryParam(key) { | |||
| 385 | + | query.Del(key) | |||
| 386 | + | } | |||
| 264 | } | 387 | } | ||
| 265 | - | // For paths like "https://github.com/user/" - also strip | |||
| 266 | - | return strings.TrimSuffix(url, "/") | |||
| 267 | } | 388 | } | ||
| 268 | - | return url | 389 | + | parsed.RawQuery = query.Encode() |
| 390 | + | ||||
| 391 | + | return parsed.String() | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func normalizePathForHistory(path string) string { | |||
| 395 | + | if path == "/" { | |||
| 396 | + | return "" | |||
| 397 | + | } | |||
| 398 | + | if strings.HasSuffix(path, "/") { | |||
| 399 | + | return strings.TrimSuffix(path, "/") | |||
| 400 | + | } | |||
| 401 | + | return path | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func isTrackingQueryParam(key string) bool { | |||
| 405 | + | key = strings.ToLower(strings.TrimSpace(key)) | |||
| 406 | + | if key == "" { | |||
| 407 | + | return false | |||
| 408 | + | } | |||
| 409 | + | if strings.HasPrefix(key, "utm_") { | |||
| 410 | + | return true | |||
| 411 | + | } | |||
| 412 | + | switch key { | |||
| 413 | + | case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid": | |||
| 414 | + | return true | |||
| 415 | + | } | |||
| 416 | + | return false | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | func isHashOnlyTransition(previous, current string) bool { | |||
| 420 | + | if previous == "" || current == "" || previous == current { | |||
| 421 | + | return false | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | prevParsed, prevErr := url.Parse(previous) | |||
| 425 | + | currParsed, currErr := url.Parse(current) | |||
| 426 | + | if prevErr != nil || currErr != nil { | |||
| 427 | + | return false | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) { | |||
| 431 | + | return false | |||
| 432 | + | } | |||
| 433 | + | if !strings.EqualFold(prevParsed.Host, currParsed.Host) { | |||
| 434 | + | return false | |||
| 435 | + | } | |||
| 436 | + | if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) { | |||
| 437 | + | return false | |||
| 438 | + | } | |||
| 439 | + | if prevParsed.RawQuery != currParsed.RawQuery { | |||
| 440 | + | return false | |||
| 441 | + | } | |||
| 442 | + | return prevParsed.Fragment != currParsed.Fragment | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func normalizePaneID(paneID string) string { | |||
| 446 | + | if strings.TrimSpace(paneID) == "" { | |||
| 447 | + | return "__default__" | |||
| 448 | + | } | |||
| 449 | + | return paneID | |||
| 269 | } | 450 | } | ||
A internal/application/usecase/navigate_test.go +60 -0
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) { | |||
| 14 | + | got := canonicalizeURLForHistory( | |||
| 15 | + | "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1", | |||
| 16 | + | historyCanonicalizationOptions{StripTrackingParams: true}, | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | require.Equal(t, "https://example.com/path?a=2&b=1", got) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) { | |||
| 23 | + | got := canonicalizeURLForHistory( | |||
| 24 | + | "https://example.com/path/?utm_source=newsletter&b=1#section-1", | |||
| 25 | + | historyCanonicalizationOptions{StripTrackingParams: false}, | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) { | |||
| 32 | + | ctx := context.Background() | |||
| 33 | + | repo := repomocks.NewMockHistoryRepository(t) | |||
| 34 | + | ||||
| 35 | + | repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once() | |||
| 36 | + | repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool { | |||
| 37 | + | return entry.URL == "https://example.com/docs" && entry.VisitCount == 1 | |||
| 38 | + | })).Return(nil).Once() | |||
| 39 | + | ||||
| 40 | + | uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault) | |||
| 41 | + | uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro") | |||
| 42 | + | uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api") | |||
| 43 | + | uc.Close() | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) { | |||
| 47 | + | ctx := context.Background() | |||
| 48 | + | repo := repomocks.NewMockHistoryRepository(t) | |||
| 49 | + | ||||
| 50 | + | repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once() | |||
| 51 | + | repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool { | |||
| 52 | + | return entry.URL == "https://example.com/article" && entry.VisitCount == 2 | |||
| 53 | + | })).Return(nil).Once() | |||
| 54 | + | ||||
| 55 | + | uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault) | |||
| 56 | + | uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed") | |||
| 57 | + | uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed") | |||
| 58 | + | uc.RecordHistory(ctx, "pane-2", "https://example.com/article") | |||
| 59 | + | uc.Close() | |||
| 60 | + | } | |||
M internal/bootstrap/session.go +247 -0
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "strings" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "syscall" | 11 | "syscall" | ||
| 11 | "time" | 12 | "time" | ||
| @@ -24,6 +25,7 @@ const ( | |||||
| 24 | recentSessionsLimit = 200 | 25 | recentSessionsLimit = 200 | ||
| 25 | lockDirPerm = 0o755 | 26 | lockDirPerm = 0o755 | ||
| 26 | lockFilePerm = 0o600 | 27 | lockFilePerm = 0o600 | ||
| 28 | + | markerFilePerm = 0o644 | |||
| 27 | ) | 29 | ) | ||
| 28 | 30 | ||||
| 29 | type BrowserSession struct { | 31 | type BrowserSession struct { | ||
| @@ -95,6 +97,19 @@ func StartBrowserSession( | |||||
| 95 | // error handling during shutdown sequences where persistFn may be called multiple times. | 97 | // error handling during shutdown sequences where persistFn may be called multiple times. | ||
| 96 | persistFn := func(persistCtx context.Context) error { | 98 | persistFn := func(persistCtx context.Context) error { | ||
| 97 | persistMu.Do(func() { | 99 | persistMu.Do(func() { | ||
| 100 | + | if lockDir != "" { | |||
| 101 | + | abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger) | |||
| 102 | + | if abruptErr != nil { | |||
| 103 | + | logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers") | |||
| 104 | + | } else { | |||
| 105 | + | for _, abruptSessionID := range abruptSessions { | |||
| 106 | + | logger.Warn(). | |||
| 107 | + | Str("session_id", abruptSessionID). | |||
| 108 | + | Msg("abrupt-exit marker detected (startup without shutdown)") | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 98 | saveCtx := corelogging.WithContext(context.Background(), logger) | 113 | saveCtx := corelogging.WithContext(context.Background(), logger) | ||
| 99 | if err := sessionRepo.Save(saveCtx, session); err != nil { | 114 | if err := sessionRepo.Save(saveCtx, session); err != nil { | ||
| 100 | persistErr = fmt.Errorf("save session: %w", err) | 115 | persistErr = fmt.Errorf("save session: %w", err) | ||
| @@ -105,6 +120,11 @@ func StartBrowserSession( | |||||
| 105 | Str("session_id", string(session.ID)). | 120 | Str("session_id", string(session.ID)). | ||
| 106 | Str("type", string(session.Type)). | 121 | Str("type", string(session.Type)). | ||
| 107 | Msg("session started") | 122 | Msg("session started") | ||
| 123 | + | if lockDir != "" { | |||
| 124 | + | if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil { | |||
| 125 | + | logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker") | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 108 | 128 | ||||
| 109 | lf, lp, lockErr := lockSessionFile(lockDir, session.ID) | 129 | lf, lp, lockErr := lockSessionFile(lockDir, session.ID) | ||
| 110 | if lockErr != nil { | 130 | if lockErr != nil { | ||
| @@ -133,6 +153,13 @@ func StartBrowserSession( | |||||
| 133 | lp := lockPath | 153 | lp := lockPath | ||
| 134 | lockMu.Unlock() | 154 | lockMu.Unlock() | ||
| 135 | 155 | ||||
| 156 | + | if lockDir != "" { | |||
| 157 | + | shutdownAt := time.Now().UTC() | |||
| 158 | + | if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil { | |||
| 159 | + | logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker") | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 136 | if lf != nil { | 163 | if lf != nil { | ||
| 137 | _ = unlockAndClose(lf) | 164 | _ = unlockAndClose(lf) | ||
| 138 | _ = os.Remove(lp) | 165 | _ = os.Remove(lp) | ||
| @@ -263,6 +290,158 @@ func unlockAndClose(f *os.File) error { | |||||
| 263 | return f.Close() | 290 | return f.Close() | ||
| 264 | } | 291 | } | ||
| 265 | 292 | ||||
| 293 | + | func startupMarkerPath(lockDir, sessionID string) string { | |||
| 294 | + | return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID)) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func shutdownMarkerPath(lockDir, sessionID string) string { | |||
| 298 | + | return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID)) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func abruptMarkerPath(lockDir, sessionID string) string { | |||
| 302 | + | return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID)) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error { | |||
| 306 | + | if lockDir == "" || sessionID == "" { | |||
| 307 | + | return errors.New("startup marker requires lockDir and sessionID") | |||
| 308 | + | } | |||
| 309 | + | if err := os.MkdirAll(lockDir, lockDirPerm); err != nil { | |||
| 310 | + | return err | |||
| 311 | + | } | |||
| 312 | + | content := []byte(startedAt.Format(time.RFC3339Nano) + "\n") | |||
| 313 | + | if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | |||
| 314 | + | return err | |||
| 315 | + | } | |||
| 316 | + | _ = os.Remove(shutdownMarkerPath(lockDir, sessionID)) | |||
| 317 | + | _ = os.Remove(abruptMarkerPath(lockDir, sessionID)) | |||
| 318 | + | return nil | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error { | |||
| 322 | + | if lockDir == "" || sessionID == "" { | |||
| 323 | + | return errors.New("shutdown marker requires lockDir and sessionID") | |||
| 324 | + | } | |||
| 325 | + | if err := os.MkdirAll(lockDir, lockDirPerm); err != nil { | |||
| 326 | + | return err | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // Preserve the startup time inside the shutdown marker so | |||
| 330 | + | // ClassifySessionExitFromMarkers can still read it after the | |||
| 331 | + | // startup marker file is removed. | |||
| 332 | + | var startupLine string | |||
| 333 | + | startupPath := startupMarkerPath(lockDir, sessionID) | |||
| 334 | + | if raw, readErr := os.ReadFile(startupPath); readErr == nil { | |||
| 335 | + | if t := strings.TrimSpace(string(raw)); t != "" { | |||
| 336 | + | startupLine = "started_at=" + t + "\n" | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine) | |||
| 341 | + | if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | |||
| 342 | + | return err | |||
| 343 | + | } | |||
| 344 | + | // Clean up the matching startup marker now that shutdown is recorded. | |||
| 345 | + | // Errors are non-fatal: the marker will be swept later if removal fails. | |||
| 346 | + | _ = os.Remove(startupPath) | |||
| 347 | + | return nil | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) { | |||
| 351 | + | if lockDir == "" { | |||
| 352 | + | return nil, nil | |||
| 353 | + | } | |||
| 354 | + | pattern := filepath.Join(lockDir, "session_*.startup.marker") | |||
| 355 | + | startupMarkers, err := filepath.Glob(pattern) | |||
| 356 | + | if err != nil { | |||
| 357 | + | return nil, err | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | abruptSessions := make([]string, 0, len(startupMarkers)) | |||
| 361 | + | for _, startupPath := range startupMarkers { | |||
| 362 | + | base := filepath.Base(startupPath) | |||
| 363 | + | if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") { | |||
| 364 | + | continue | |||
| 365 | + | } | |||
| 366 | + | sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker") | |||
| 367 | + | if sessionID == "" { | |||
| 368 | + | continue | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID)) | |||
| 372 | + | f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm) | |||
| 373 | + | if openErr != nil { | |||
| 374 | + | if os.IsNotExist(openErr) { | |||
| 375 | + | // No lock file: fall through to mark as abrupt. | |||
| 376 | + | } else { | |||
| 377 | + | if logger != nil { | |||
| 378 | + | logger.Warn(). | |||
| 379 | + | Err(openErr). | |||
| 380 | + | Str("session_id", sessionID). | |||
| 381 | + | Str("path", lockPath). | |||
| 382 | + | Msg("markAbruptExits: open lock file failed") | |||
| 383 | + | } | |||
| 384 | + | continue | |||
| 385 | + | } | |||
| 386 | + | } else { | |||
| 387 | + | locked, lockErr := tryLockExclusiveNonBlocking(f) | |||
| 388 | + | if lockErr != nil { | |||
| 389 | + | _ = f.Close() | |||
| 390 | + | if logger != nil { | |||
| 391 | + | logger.Warn(). | |||
| 392 | + | Err(lockErr). | |||
| 393 | + | Str("session_id", sessionID). | |||
| 394 | + | Str("path", lockPath). | |||
| 395 | + | Msg("markAbruptExits: lock probe failed") | |||
| 396 | + | } | |||
| 397 | + | continue | |||
| 398 | + | } | |||
| 399 | + | if !locked { | |||
| 400 | + | // Lock is held by another process: session still active. | |||
| 401 | + | _ = f.Close() | |||
| 402 | + | continue | |||
| 403 | + | } | |||
| 404 | + | // Lock acquired: no process holds it, session is stale. | |||
| 405 | + | _ = unlockAndClose(f) | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | shutdownPath := shutdownMarkerPath(lockDir, sessionID) | |||
| 409 | + | if _, err := os.Stat(shutdownPath); err == nil { | |||
| 410 | + | continue | |||
| 411 | + | } else if !os.IsNotExist(err) { | |||
| 412 | + | if logger != nil { | |||
| 413 | + | logger.Warn(). | |||
| 414 | + | Err(err). | |||
| 415 | + | Str("session_id", sessionID). | |||
| 416 | + | Str("path", shutdownPath). | |||
| 417 | + | Msg("markAbruptExits: stat failed") | |||
| 418 | + | } | |||
| 419 | + | continue | |||
| 420 | + | } | |||
| 421 | + | abruptPath := abruptMarkerPath(lockDir, sessionID) | |||
| 422 | + | if _, err := os.Stat(abruptPath); err == nil { | |||
| 423 | + | continue | |||
| 424 | + | } else if !os.IsNotExist(err) { | |||
| 425 | + | if logger != nil { | |||
| 426 | + | logger.Warn(). | |||
| 427 | + | Err(err). | |||
| 428 | + | Str("session_id", sessionID). | |||
| 429 | + | Str("path", abruptPath). | |||
| 430 | + | Msg("markAbruptExits: stat failed") | |||
| 431 | + | } | |||
| 432 | + | continue | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | payload := []byte(fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)) | |||
| 436 | + | if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), payload, markerFilePerm); err != nil { | |||
| 437 | + | return abruptSessions, err | |||
| 438 | + | } | |||
| 439 | + | abruptSessions = append(abruptSessions, sessionID) | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | return abruptSessions, nil | |||
| 443 | + | } | |||
| 444 | + | ||||
| 266 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | 445 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | ||
| 267 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | 446 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | ||
| 268 | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | 447 | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | ||
| @@ -306,5 +485,73 @@ func runSessionCleanupAsync( | |||||
| 306 | log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions") | 485 | log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions") | ||
| 307 | } | 486 | } | ||
| 308 | } | 487 | } | ||
| 488 | + | ||||
| 489 | + | // Sweep paired startup+shutdown markers that are no longer needed. | |||
| 490 | + | if lockDir != "" { | |||
| 491 | + | sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log) | |||
| 492 | + | } | |||
| 309 | }() | 493 | }() | ||
| 310 | } | 494 | } | ||
| 495 | + | ||||
| 496 | + | // sweepPairedMarkers removes marker files for sessions that have both a | |||
| 497 | + | // shutdown marker and a startup marker older than maxAgeDays. Both markers | |||
| 498 | + | // must exist and be aged past the cutoff before any files are removed. | |||
| 499 | + | // The associated abrupt marker (if present) is also cleaned up. | |||
| 500 | + | // Removal errors are non-fatal (logged and skipped). | |||
| 501 | + | func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) { | |||
| 502 | + | if lockDir == "" { | |||
| 503 | + | return | |||
| 504 | + | } | |||
| 505 | + | if maxAgeDays <= 0 { | |||
| 506 | + | maxAgeDays = 7 | |||
| 507 | + | } | |||
| 508 | + | cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour) | |||
| 509 | + | ||||
| 510 | + | shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker")) | |||
| 511 | + | if err != nil { | |||
| 512 | + | if log != nil { | |||
| 513 | + | log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed") | |||
| 514 | + | } | |||
| 515 | + | return | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | swept := 0 | |||
| 519 | + | for _, shutdownPath := range shutdownMarkers { | |||
| 520 | + | base := filepath.Base(shutdownPath) | |||
| 521 | + | if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") { | |||
| 522 | + | continue | |||
| 523 | + | } | |||
| 524 | + | sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker") | |||
| 525 | + | if sessionID == "" { | |||
| 526 | + | continue | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | shutdownInfo, err := os.Stat(shutdownPath) | |||
| 530 | + | if err != nil || shutdownInfo.ModTime().After(cutoff) { | |||
| 531 | + | continue | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | startupPath := startupMarkerPath(lockDir, sessionID) | |||
| 535 | + | startupInfo, err := os.Stat(startupPath) | |||
| 536 | + | if err != nil || startupInfo.ModTime().After(cutoff) { | |||
| 537 | + | // Startup marker missing or too recent — skip this session. | |||
| 538 | + | continue | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | abruptPath := abruptMarkerPath(lockDir, sessionID) | |||
| 542 | + | ||||
| 543 | + | // Remove the trio: shutdown, startup, abrupt (if present). | |||
| 544 | + | for _, p := range []string{shutdownPath, startupPath, abruptPath} { | |||
| 545 | + | if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) { | |||
| 546 | + | if log != nil { | |||
| 547 | + | log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed") | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | swept++ | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | if swept > 0 && log != nil { | |||
| 555 | + | log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files") | |||
| 556 | + | } | |||
| 557 | + | } | |||
A internal/bootstrap/session_exit_classification.go +209 -0
| @@ -0,0 +1,209 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "sort" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type SessionExitClass string | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | SessionExitCleanExit SessionExitClass = "clean_exit" | |||
| 16 | + | SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt" | |||
| 17 | + | SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred" | |||
| 18 | + | SessionExitUnknown SessionExitClass = "unknown" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type SessionExitClassification struct { | |||
| 22 | + | SessionID string | |||
| 23 | + | Class SessionExitClass | |||
| 24 | + | Inference string | |||
| 25 | + | Reason string | |||
| 26 | + | StartupAt *time.Time | |||
| 27 | + | ShutdownAt *time.Time | |||
| 28 | + | AbruptDetectedAt *time.Time | |||
| 29 | + | LastMarkerObserved time.Time | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) { | |||
| 33 | + | classification := SessionExitClassification{ | |||
| 34 | + | SessionID: sessionID, | |||
| 35 | + | Class: SessionExitUnknown, | |||
| 36 | + | Inference: "marker-missing", | |||
| 37 | + | Reason: "no known marker files found for session", | |||
| 38 | + | } | |||
| 39 | + | if lockDir == "" || sessionID == "" { | |||
| 40 | + | return classification, fmt.Errorf("lockDir and sessionID are required") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "") | |||
| 44 | + | if err != nil { | |||
| 45 | + | return classification, err | |||
| 46 | + | } | |||
| 47 | + | shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "") | |||
| 48 | + | if err != nil { | |||
| 49 | + | return classification, err | |||
| 50 | + | } | |||
| 51 | + | abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=") | |||
| 52 | + | if err != nil { | |||
| 53 | + | return classification, err | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // If the startup marker was already cleaned up (writeShutdownMarker removes | |||
| 57 | + | // it), fall back to the started_at line embedded in the shutdown marker. | |||
| 58 | + | if startupAt == nil && shutdownExists { | |||
| 59 | + | startupAt, _, _, _ = readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "started_at=") | |||
| 60 | + | } | |||
| 61 | + | classification.StartupAt = startupAt | |||
| 62 | + | classification.ShutdownAt = shutdownAt | |||
| 63 | + | classification.AbruptDetectedAt = abruptAt | |||
| 64 | + | classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime) | |||
| 65 | + | ||||
| 66 | + | switch { | |||
| 67 | + | case shutdownExists: | |||
| 68 | + | classification.Class = SessionExitCleanExit | |||
| 69 | + | classification.Inference = "marker-confirmed" | |||
| 70 | + | classification.Reason = "shutdown marker present" | |||
| 71 | + | case abruptExists: | |||
| 72 | + | classification.Class = SessionExitMainProcessCrashOrAbrupt | |||
| 73 | + | classification.Inference = "marker-confirmed" | |||
| 74 | + | classification.Reason = "abrupt marker present and no shutdown marker" | |||
| 75 | + | case startupExists: | |||
| 76 | + | classification.Class = SessionExitExternalKillOrOOMInferred | |||
| 77 | + | classification.Inference = "best-effort" | |||
| 78 | + | classification.Reason = "startup marker present without shutdown/abrupt markers" | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return classification, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) { | |||
| 85 | + | if lockDir == "" { | |||
| 86 | + | return nil, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | sessionIDs, err := discoverSessionIDsFromMarkers(lockDir) | |||
| 90 | + | if err != nil { | |||
| 91 | + | return nil, err | |||
| 92 | + | } | |||
| 93 | + | if len(sessionIDs) == 0 { | |||
| 94 | + | return nil, nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | report := make([]SessionExitClassification, 0, len(sessionIDs)) | |||
| 98 | + | for _, sessionID := range sessionIDs { | |||
| 99 | + | classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 100 | + | if classErr != nil { | |||
| 101 | + | return nil, classErr | |||
| 102 | + | } | |||
| 103 | + | report = append(report, classification) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | sort.Slice(report, func(i, j int) bool { | |||
| 107 | + | return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved) | |||
| 108 | + | }) | |||
| 109 | + | return report, nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) { | |||
| 113 | + | pattern := filepath.Join(lockDir, "session_*.marker") | |||
| 114 | + | paths, err := filepath.Glob(pattern) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return nil, err | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | ids := make(map[string]struct{}, len(paths)) | |||
| 120 | + | for _, path := range paths { | |||
| 121 | + | base := filepath.Base(path) | |||
| 122 | + | sessionID, ok := markerSessionID(base) | |||
| 123 | + | if !ok { | |||
| 124 | + | continue | |||
| 125 | + | } | |||
| 126 | + | ids[sessionID] = struct{}{} | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | sessionIDs := make([]string, 0, len(ids)) | |||
| 130 | + | for id := range ids { | |||
| 131 | + | sessionIDs = append(sessionIDs, id) | |||
| 132 | + | } | |||
| 133 | + | sort.Strings(sessionIDs) | |||
| 134 | + | return sessionIDs, nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func markerSessionID(base string) (string, bool) { | |||
| 138 | + | if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") { | |||
| 139 | + | return "", false | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker") | |||
| 143 | + | switch { | |||
| 144 | + | case strings.HasSuffix(trimmed, ".startup"): | |||
| 145 | + | return strings.TrimSuffix(trimmed, ".startup"), true | |||
| 146 | + | case strings.HasSuffix(trimmed, ".shutdown"): | |||
| 147 | + | return strings.TrimSuffix(trimmed, ".shutdown"), true | |||
| 148 | + | case strings.HasSuffix(trimmed, ".abrupt"): | |||
| 149 | + | return strings.TrimSuffix(trimmed, ".abrupt"), true | |||
| 150 | + | default: | |||
| 151 | + | return "", false | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) { | |||
| 156 | + | stat, err := os.Stat(path) | |||
| 157 | + | if err != nil { | |||
| 158 | + | if os.IsNotExist(err) { | |||
| 159 | + | return nil, false, time.Time{}, nil | |||
| 160 | + | } | |||
| 161 | + | return nil, false, time.Time{}, err | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | content, err := os.ReadFile(path) | |||
| 165 | + | if err != nil { | |||
| 166 | + | return nil, true, stat.ModTime(), err | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | raw := strings.TrimSpace(string(content)) | |||
| 170 | + | if raw == "" { | |||
| 171 | + | return nil, true, stat.ModTime(), nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | if keyPrefix != "" { | |||
| 175 | + | lines := strings.Split(raw, "\n") | |||
| 176 | + | for _, line := range lines { | |||
| 177 | + | if strings.HasPrefix(line, keyPrefix) { | |||
| 178 | + | parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix)) | |||
| 179 | + | if parseErr != nil { | |||
| 180 | + | return nil, true, stat.ModTime(), nil | |||
| 181 | + | } | |||
| 182 | + | return &parsed, true, stat.ModTime(), nil | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | return nil, true, stat.ModTime(), nil | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // When no key prefix is given, parse the first line as the timestamp. | |||
| 189 | + | // Multi-line markers may contain additional metadata (e.g. started_at=). | |||
| 190 | + | firstLine, _, _ := strings.Cut(raw, "\n") | |||
| 191 | + | parsed, parseErr := time.Parse(time.RFC3339Nano, firstLine) | |||
| 192 | + | if parseErr != nil { | |||
| 193 | + | return nil, true, stat.ModTime(), nil | |||
| 194 | + | } | |||
| 195 | + | return &parsed, true, stat.ModTime(), nil | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func latestNonZero(values ...time.Time) time.Time { | |||
| 199 | + | var latest time.Time | |||
| 200 | + | for _, value := range values { | |||
| 201 | + | if value.IsZero() { | |||
| 202 | + | continue | |||
| 203 | + | } | |||
| 204 | + | if latest.IsZero() || value.After(latest) { | |||
| 205 | + | latest = value | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | return latest | |||
| 209 | + | } | |||
A internal/bootstrap/session_exit_classification_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) { | |||
| 14 | + | lockDir := t.TempDir() | |||
| 15 | + | sessionID := "clean-1" | |||
| 16 | + | startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC) | |||
| 17 | + | endedAt := startedAt.Add(2 * time.Minute) | |||
| 18 | + | ||||
| 19 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 20 | + | require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt)) | |||
| 21 | + | ||||
| 22 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | assert.Equal(t, SessionExitCleanExit, classification.Class) | |||
| 25 | + | assert.Equal(t, "marker-confirmed", classification.Inference) | |||
| 26 | + | assert.Equal(t, "shutdown marker present", classification.Reason) | |||
| 27 | + | require.NotNil(t, classification.StartupAt) | |||
| 28 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |||
| 29 | + | require.NotNil(t, classification.ShutdownAt) | |||
| 30 | + | assert.Equal(t, endedAt, *classification.ShutdownAt) | |||
| 31 | + | assert.Nil(t, classification.AbruptDetectedAt) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) { | |||
| 35 | + | lockDir := t.TempDir() | |||
| 36 | + | sessionID := "abrupt-1" | |||
| 37 | + | startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC) | |||
| 38 | + | detectedAt := startedAt.Add(5 * time.Minute) | |||
| 39 | + | ||||
| 40 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 41 | + | require.NoError(t, os.WriteFile( | |||
| 42 | + | abruptMarkerPath(lockDir, sessionID), | |||
| 43 | + | []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"), | |||
| 44 | + | markerFilePerm, | |||
| 45 | + | )) | |||
| 46 | + | ||||
| 47 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class) | |||
| 50 | + | assert.Equal(t, "marker-confirmed", classification.Inference) | |||
| 51 | + | assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason) | |||
| 52 | + | require.NotNil(t, classification.StartupAt) | |||
| 53 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |||
| 54 | + | assert.Nil(t, classification.ShutdownAt) | |||
| 55 | + | require.NotNil(t, classification.AbruptDetectedAt) | |||
| 56 | + | assert.Equal(t, detectedAt, *classification.AbruptDetectedAt) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) { | |||
| 60 | + | lockDir := t.TempDir() | |||
| 61 | + | sessionID := "inferred-1" | |||
| 62 | + | startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |||
| 63 | + | ||||
| 64 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 65 | + | ||||
| 66 | + | classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class) | |||
| 69 | + | assert.Equal(t, "best-effort", classification.Inference) | |||
| 70 | + | assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason) | |||
| 71 | + | require.NotNil(t, classification.StartupAt) | |||
| 72 | + | assert.Equal(t, startedAt, *classification.StartupAt) | |||
| 73 | + | assert.Nil(t, classification.ShutdownAt) | |||
| 74 | + | assert.Nil(t, classification.AbruptDetectedAt) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) { | |||
| 78 | + | _, err := ClassifySessionExitFromMarkers("", "s1") | |||
| 79 | + | require.Error(t, err) | |||
| 80 | + | ||||
| 81 | + | _, err = ClassifySessionExitFromMarkers(t.TempDir(), "") | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestBuildSessionExitReport(t *testing.T) { | |||
| 86 | + | lockDir := t.TempDir() | |||
| 87 | + | ||||
| 88 | + | require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC))) | |||
| 89 | + | require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC))) | |||
| 90 | + | ||||
| 91 | + | require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC))) | |||
| 92 | + | require.NoError(t, os.WriteFile( | |||
| 93 | + | abruptMarkerPath(lockDir, "abrupt-r"), | |||
| 94 | + | []byte("detected_at=2026-02-07T08:08:00Z\n"), | |||
| 95 | + | markerFilePerm, | |||
| 96 | + | )) | |||
| 97 | + | ||||
| 98 | + | require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC))) | |||
| 99 | + | ||||
| 100 | + | // Unknown marker type should not be included. | |||
| 101 | + | require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm)) | |||
| 102 | + | ||||
| 103 | + | report, err := BuildSessionExitReport(lockDir) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Len(t, report, 3) | |||
| 106 | + | ||||
| 107 | + | got := map[string]SessionExitClass{} | |||
| 108 | + | for _, item := range report { | |||
| 109 | + | got[item.SessionID] = item.Class | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | assert.Equal(t, SessionExitCleanExit, got["clean-r"]) | |||
| 113 | + | assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"]) | |||
| 114 | + | assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"]) | |||
| 115 | + | } | |||
A internal/bootstrap/session_markers_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) { | |||
| 14 | + | lockDir := t.TempDir() | |||
| 15 | + | sessionID := "s123" | |||
| 16 | + | startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC) | |||
| 17 | + | endedAt := startedAt.Add(3 * time.Minute) | |||
| 18 | + | ||||
| 19 | + | err := writeStartupMarker(lockDir, sessionID, startedAt) | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | ||||
| 22 | + | startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID)) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano)) | |||
| 25 | + | ||||
| 26 | + | err = writeShutdownMarker(lockDir, sessionID, endedAt) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | ||||
| 29 | + | shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID)) | |||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano)) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestSessionMarkers_MarkAbruptExits(t *testing.T) { | |||
| 35 | + | lockDir := t.TempDir() | |||
| 36 | + | detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |||
| 37 | + | ||||
| 38 | + | require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute))) | |||
| 39 | + | require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute))) | |||
| 40 | + | require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute))) | |||
| 41 | + | ||||
| 42 | + | abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | assert.Equal(t, []string{"abrupt-a"}, abruptSessions) | |||
| 45 | + | ||||
| 46 | + | abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a")) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano)) | |||
| 49 | + | assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker")) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) { | |||
| 53 | + | lockDir := t.TempDir() | |||
| 54 | + | detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC) | |||
| 55 | + | ||||
| 56 | + | require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute))) | |||
| 57 | + | ||||
| 58 | + | first, err := markAbruptExits(lockDir, detectedAt, nil) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | assert.Equal(t, []string{"abrupt-c"}, first) | |||
| 61 | + | ||||
| 62 | + | second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | assert.Empty(t, second) | |||
| 65 | + | } | |||
M internal/bootstrap/webkit_stack.go +21 -4
| @@ -46,7 +46,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack { | |||||
| 46 | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | 46 | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | ||
| 47 | logging.Trace().Mark("render_env") | 47 | logging.Trace().Mark("render_env") | ||
| 48 | 48 | ||||
| 49 | - | wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings) | 49 | + | wkOpts := buildWebKitContextOptions(cfg, input.DataDir, input.CacheDir, &perfSettings) |
| 50 | + | logger.Info(). | |||
| 51 | + | Str("cookie_policy", string(wkOpts.CookiePolicy)). | |||
| 52 | + | Bool("itp_enabled", wkOpts.ITPEnabled). | |||
| 53 | + | Msg("webkit privacy configuration") | |||
| 50 | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | 54 | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | ||
| 51 | if err != nil { | 55 | if err != nil { | ||
| 52 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | 56 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | ||
| @@ -97,10 +101,23 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo | |||||
| 97 | return perfSettings | 101 | return perfSettings | ||
| 98 | } | 102 | } | ||
| 99 | 103 | ||||
| 100 | - | func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions { | 104 | + | func buildWebKitContextOptions( |
| 105 | + | cfg *config.Config, | |||
| 106 | + | dataDir, cacheDir string, | |||
| 107 | + | perfSettings *config.ResolvedPerformanceSettings, | |||
| 108 | + | ) port.WebKitContextOptions { | |||
| 109 | + | cookiePolicy := port.WebKitCookiePolicyNoThirdParty | |||
| 110 | + | itpEnabled := true | |||
| 111 | + | if cfg != nil { | |||
| 112 | + | cookiePolicy = port.WebKitCookiePolicy(cfg.Privacy.CookiePolicy) | |||
| 113 | + | itpEnabled = cfg.Privacy.ITPEnabled | |||
| 114 | + | } | |||
| 115 | + | ||||
| 101 | wkOpts := port.WebKitContextOptions{ | 116 | wkOpts := port.WebKitContextOptions{ | ||
| 102 | - | DataDir: dataDir, | 117 | + | DataDir: dataDir, |
| 103 | - | CacheDir: cacheDir, | 118 | + | CacheDir: cacheDir, |
| 119 | + | CookiePolicy: cookiePolicy, | |||
| 120 | + | ITPEnabled: itpEnabled, | |||
| 104 | } | 121 | } | ||
| 105 | 122 | ||||
| 106 | if hasWebProcessMemoryConfig(perfSettings) { | 123 | if hasWebProcessMemoryConfig(perfSettings) { | ||
A internal/bootstrap/webkit_stack_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) { | |||
| 13 | + | cfg := config.DefaultConfig() | |||
| 14 | + | cfg.Privacy.CookiePolicy = config.CookiePolicyNever | |||
| 15 | + | cfg.Privacy.ITPEnabled = false | |||
| 16 | + | ||||
| 17 | + | perf := &config.ResolvedPerformanceSettings{ | |||
| 18 | + | WebProcessMemoryLimitMB: 1024, | |||
| 19 | + | WebProcessMemoryPollIntervalSec: 11, | |||
| 20 | + | WebProcessMemoryConservativeThreshold: 0.25, | |||
| 21 | + | WebProcessMemoryStrictThreshold: 0.5, | |||
| 22 | + | NetworkProcessMemoryLimitMB: 512, | |||
| 23 | + | NetworkProcessMemoryPollIntervalSec: 13, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | opts := buildWebKitContextOptions(cfg, "/tmp/data", "/tmp/cache", perf) | |||
| 27 | + | ||||
| 28 | + | assert.Equal(t, "/tmp/data", opts.DataDir) | |||
| 29 | + | assert.Equal(t, "/tmp/cache", opts.CacheDir) | |||
| 30 | + | assert.Equal(t, port.WebKitCookiePolicyNever, opts.CookiePolicy) | |||
| 31 | + | assert.False(t, opts.ITPEnabled) | |||
| 32 | + | ||||
| 33 | + | require.NotNil(t, opts.WebProcessMemory) | |||
| 34 | + | assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB) | |||
| 35 | + | assert.InDelta(t, 11.0, opts.WebProcessMemory.PollIntervalSec, 1e-9) | |||
| 36 | + | assert.InDelta(t, 0.25, opts.WebProcessMemory.ConservativeThreshold, 1e-9) | |||
| 37 | + | assert.InDelta(t, 0.5, opts.WebProcessMemory.StrictThreshold, 1e-9) | |||
| 38 | + | ||||
| 39 | + | require.NotNil(t, opts.NetworkProcessMemory) | |||
| 40 | + | assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB) | |||
| 41 | + | assert.InDelta(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec, 1e-9) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) { | |||
| 45 | + | opts := buildWebKitContextOptions(nil, "/tmp/data", "/tmp/cache", &config.ResolvedPerformanceSettings{}) | |||
| 46 | + | ||||
| 47 | + | assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy) | |||
| 48 | + | assert.True(t, opts.ITPEnabled) | |||
| 49 | + | } | |||
M internal/domain/autocomplete/autocomplete.go +123 -1
| @@ -1,7 +1,11 @@ | |||||
| 1 | // Package autocomplete provides domain types and logic for URL autocompletion. | 1 | // Package autocomplete provides domain types and logic for URL autocompletion. | ||
| 2 | package autocomplete | 2 | package autocomplete | ||
| 3 | 3 | ||||
| 4 | - | import "strings" | 4 | + | import ( |
| 5 | + | stdurl "net/url" | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ) | |||
| 5 | 9 | ||||
| 6 | // SuggestionSource indicates the origin of an autocomplete suggestion. | 10 | // SuggestionSource indicates the origin of an autocomplete suggestion. | ||
| 7 | type SuggestionSource int | 11 | type SuggestionSource int | ||
| @@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin | |||||
| 78 | 82 | ||||
| 79 | return "", "", false | 83 | return "", "", false | ||
| 80 | } | 84 | } | ||
| 85 | + | ||||
| 86 | + | // BestURLCompletion returns the best completion candidate from a list of URLs. | |||
| 87 | + | // For host-like queries (for example "goo"), it prefers short host completions | |||
| 88 | + | // over deep redirect paths. | |||
| 89 | + | func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) { | |||
| 90 | + | suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls) | |||
| 91 | + | return suffix, matchedURL, ok | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // BestURLCompletionWithIndex returns the best completion candidate and its source URL index. | |||
| 95 | + | func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) { | |||
| 96 | + | bestScore := completionScore{ | |||
| 97 | + | valid: false, | |||
| 98 | + | } | |||
| 99 | + | bestSuffix := "" | |||
| 100 | + | bestMatched := "" | |||
| 101 | + | bestIndex := -1 | |||
| 102 | + | ||||
| 103 | + | for idx, rawURL := range urls { | |||
| 104 | + | if rawURL == "" { | |||
| 105 | + | continue | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL) | |||
| 109 | + | if !candidateOK { | |||
| 110 | + | continue | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | score := rankCompletion(candidateText, candidateSuffix, idx) | |||
| 114 | + | if !bestScore.valid || score.less(bestScore) { | |||
| 115 | + | bestScore = score | |||
| 116 | + | bestSuffix = candidateSuffix | |||
| 117 | + | bestMatched = candidateText | |||
| 118 | + | bestIndex = idx | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if !bestScore.valid { | |||
| 123 | + | return "", "", -1, false | |||
| 124 | + | } | |||
| 125 | + | return bestSuffix, bestMatched, bestIndex, true | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) { | |||
| 129 | + | if looksLikeHostInput(input) { | |||
| 130 | + | if host := hostForCompletion(rawURL); host != "" { | |||
| 131 | + | for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} { | |||
| 132 | + | if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK { | |||
| 133 | + | return candidate, completionSuffix, true | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL) | |||
| 140 | + | if !completionOK { | |||
| 141 | + | return "", "", false | |||
| 142 | + | } | |||
| 143 | + | return completionMatched, completionSuffix, true | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func looksLikeHostInput(input string) bool { | |||
| 147 | + | if input == "" || strings.HasPrefix(input, "!") { | |||
| 148 | + | return false | |||
| 149 | + | } | |||
| 150 | + | return !strings.ContainsAny(input, "/?#=& ") | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func hostForCompletion(raw string) string { | |||
| 154 | + | if raw == "" { | |||
| 155 | + | return "" | |||
| 156 | + | } | |||
| 157 | + | if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" { | |||
| 158 | + | return strings.ToLower(parsed.Hostname()) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | trimmed := strings.ToLower(StripProtocol(raw)) | |||
| 162 | + | for _, sep := range []string{"/", "?", "#"} { | |||
| 163 | + | if idx := strings.Index(trimmed, sep); idx >= 0 { | |||
| 164 | + | trimmed = trimmed[:idx] | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | return strings.TrimSpace(trimmed) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | type completionScore struct { | |||
| 171 | + | valid bool | |||
| 172 | + | hasPathOrArgs bool | |||
| 173 | + | suffixLen int | |||
| 174 | + | totalLen int | |||
| 175 | + | index int | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func rankCompletion(matchedURL, suffix string, index int) completionScore { | |||
| 179 | + | return completionScore{ | |||
| 180 | + | valid: true, | |||
| 181 | + | hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"), | |||
| 182 | + | suffixLen: utf8.RuneCountInString(suffix), | |||
| 183 | + | totalLen: utf8.RuneCountInString(matchedURL), | |||
| 184 | + | index: index, | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (s completionScore) less(other completionScore) bool { | |||
| 189 | + | if s.hasPathOrArgs != other.hasPathOrArgs { | |||
| 190 | + | return !s.hasPathOrArgs | |||
| 191 | + | } | |||
| 192 | + | if s.index != other.index { | |||
| 193 | + | return s.index < other.index | |||
| 194 | + | } | |||
| 195 | + | if s.suffixLen != other.suffixLen { | |||
| 196 | + | return s.suffixLen < other.suffixLen | |||
| 197 | + | } | |||
| 198 | + | if s.totalLen != other.totalLen { | |||
| 199 | + | return s.totalLen < other.totalLen | |||
| 200 | + | } | |||
| 201 | + | return false | |||
| 202 | + | } | |||
M internal/domain/autocomplete/autocomplete_test.go +53 -0
| @@ -266,3 +266,56 @@ func TestComputeURLCompletionSuffix(t *testing.T) { | |||||
| 266 | }) | 266 | }) | ||
| 267 | } | 267 | } | ||
| 268 | } | 268 | } | ||
| 269 | + | ||||
| 270 | + | func TestBestURLCompletion_PrefersHostForHostLikeInput(t *testing.T) { | |||
| 271 | + | urls := []string{ | |||
| 272 | + | "https://www.google.com/url?q=https://dashboard.stripe.com/auth", | |||
| 273 | + | "https://google.com", | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | suffix, matchedURL, ok := BestURLCompletion("goo", urls) | |||
| 277 | + | if !ok { | |||
| 278 | + | t.Fatalf("expected completion") | |||
| 279 | + | } | |||
| 280 | + | if matchedURL != "google.com" { | |||
| 281 | + | t.Fatalf("expected host completion, got %q", matchedURL) | |||
| 282 | + | } | |||
| 283 | + | if suffix != "gle.com" { | |||
| 284 | + | t.Fatalf("expected host suffix, got %q", suffix) | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func TestBestURLCompletion_KeepsPathForPathLikeInput(t *testing.T) { | |||
| 289 | + | urls := []string{ | |||
| 290 | + | "https://google.com/url?q=https://example.com", | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | suffix, matchedURL, ok := BestURLCompletion("google.com/u", urls) | |||
| 294 | + | if !ok { | |||
| 295 | + | t.Fatalf("expected completion") | |||
| 296 | + | } | |||
| 297 | + | if matchedURL != "google.com/url?q=https://example.com" { | |||
| 298 | + | t.Fatalf("unexpected matched URL: %q", matchedURL) | |||
| 299 | + | } | |||
| 300 | + | if suffix != "rl?q=https://example.com" { | |||
| 301 | + | t.Fatalf("unexpected suffix: %q", suffix) | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func TestBestURLCompletion_SelectsAnyValidPrefixFromVisibleList(t *testing.T) { | |||
| 306 | + | urls := []string{ | |||
| 307 | + | "https://example.org", | |||
| 308 | + | "https://google.com/maps", | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | suffix, matchedURL, ok := BestURLCompletion("goo", urls) | |||
| 312 | + | if !ok { | |||
| 313 | + | t.Fatalf("expected completion from second URL") | |||
| 314 | + | } | |||
| 315 | + | if matchedURL != "google.com" { | |||
| 316 | + | t.Fatalf("expected host completion from second URL, got %q", matchedURL) | |||
| 317 | + | } | |||
| 318 | + | if suffix != "gle.com" { | |||
| 319 | + | t.Fatalf("unexpected suffix: %q", suffix) | |||
| 320 | + | } | |||
| 321 | + | } | |||
M internal/infrastructure/config/defaults.go +5 -1
| @@ -153,6 +153,10 @@ func DefaultConfig() *Config { | |||||
| 153 | SampleMemory: false, | 153 | SampleMemory: false, | ||
| 154 | DebugFrames: false, | 154 | DebugFrames: false, | ||
| 155 | }, | 155 | }, | ||
| 156 | + | Privacy: PrivacyConfig{ | |||
| 157 | + | CookiePolicy: CookiePolicyNoThirdParty, | |||
| 158 | + | ITPEnabled: true, | |||
| 159 | + | }, | |||
| 156 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | 160 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | ||
| 157 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | 161 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | ||
| 158 | Workspace: WorkspaceConfig{ | 162 | Workspace: WorkspaceConfig{ | ||
| @@ -289,7 +293,7 @@ func DefaultConfig() *Config { | |||||
| 289 | Prefix: "", | 293 | Prefix: "", | ||
| 290 | }, | 294 | }, | ||
| 291 | Performance: PerformanceConfig{ | 295 | Performance: PerformanceConfig{ | ||
| 292 | - | Profile: ProfileDefault, // Use WebKit defaults, no tuning | 296 | + | Profile: ProfileDefault, // Use WebKit defaults by default |
| 293 | ZoomCacheSize: defaultZoomCacheSize, | 297 | ZoomCacheSize: defaultZoomCacheSize, | ||
| 294 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | 298 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | ||
| 295 | // Skia threading - balanced defaults (unset = use WebKit defaults) | 299 | // Skia threading - balanced defaults (unset = use WebKit defaults) | ||
A internal/infrastructure/config/defaults_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) { | |||
| 10 | + | cfg := DefaultConfig() | |||
| 11 | + | assert.Equal(t, "info", cfg.Logging.Level) | |||
| 12 | + | assert.False(t, cfg.Logging.CaptureGTKLogs) | |||
| 13 | + | assert.Equal(t, ProfileDefault, cfg.Performance.Profile) | |||
| 14 | + | assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy) | |||
| 15 | + | assert.True(t, cfg.Privacy.ITPEnabled) | |||
| 16 | + | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | |||
| 17 | + | } | |||
M internal/infrastructure/config/loader.go +41 -6
| @@ -177,6 +177,16 @@ func ensureDatabasePath(config *Config) error { | |||||
| 177 | } | 177 | } | ||
| 178 | 178 | ||||
| 179 | func normalizeConfig(config *Config) { | 179 | func normalizeConfig(config *Config) { | ||
| 180 | + | normalizeRendering(config) | |||
| 181 | + | normalizePrivacy(config) | |||
| 182 | + | normalizeAppearance(config) | |||
| 183 | + | normalizeMedia(config) | |||
| 184 | + | ||||
| 185 | + | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |||
| 186 | + | normalizePerformanceProfile(config) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func normalizeRendering(config *Config) { | |||
| 180 | switch strings.ToLower(string(config.Rendering.Mode)) { | 190 | switch strings.ToLower(string(config.Rendering.Mode)) { | ||
| 181 | case "", string(RenderingModeAuto): | 191 | case "", string(RenderingModeAuto): | ||
| 182 | config.Rendering.Mode = RenderingModeAuto | 192 | config.Rendering.Mode = RenderingModeAuto | ||
| @@ -200,15 +210,35 @@ func normalizeConfig(config *Config) { | |||||
| 200 | default: | 210 | default: | ||
| 201 | config.Rendering.GSKRenderer = GSKRendererAuto | 211 | config.Rendering.GSKRenderer = GSKRendererAuto | ||
| 202 | } | 212 | } | ||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func normalizePrivacy(config *Config) { | |||
| 216 | + | switch strings.ToLower(string(config.Privacy.CookiePolicy)) { | |||
| 217 | + | case "", string(CookiePolicyNoThirdParty): | |||
| 218 | + | config.Privacy.CookiePolicy = CookiePolicyNoThirdParty | |||
| 219 | + | case string(CookiePolicyAlways): | |||
| 220 | + | config.Privacy.CookiePolicy = CookiePolicyAlways | |||
| 221 | + | case string(CookiePolicyNever): | |||
| 222 | + | config.Privacy.CookiePolicy = CookiePolicyNever | |||
| 223 | + | default: | |||
| 224 | + | config.Privacy.CookiePolicy = CookiePolicyNoThirdParty | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 203 | 227 | ||||
| 204 | - | switch config.Appearance.ColorScheme { | 228 | + | func normalizeAppearance(config *Config) { |
| 205 | - | case ThemePreferDark, ThemePreferLight, ThemeDefault: | 229 | + | switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) { |
| 206 | - | case "": | 230 | + | case strings.ToLower(ThemePreferDark): |
| 231 | + | config.Appearance.ColorScheme = ThemePreferDark | |||
| 232 | + | case strings.ToLower(ThemePreferLight): | |||
| 233 | + | config.Appearance.ColorScheme = ThemePreferLight | |||
| 234 | + | case strings.ToLower(ThemeDefault): | |||
| 207 | config.Appearance.ColorScheme = ThemeDefault | 235 | config.Appearance.ColorScheme = ThemeDefault | ||
| 208 | default: | 236 | default: | ||
| 209 | config.Appearance.ColorScheme = ThemeDefault | 237 | config.Appearance.ColorScheme = ThemeDefault | ||
| 210 | } | 238 | } | ||
| 239 | + | } | |||
| 211 | 240 | ||||
| 241 | + | func normalizeMedia(config *Config) { | |||
| 212 | switch strings.ToLower(string(config.Media.HardwareDecodingMode)) { | 242 | switch strings.ToLower(string(config.Media.HardwareDecodingMode)) { | ||
| 213 | case "", string(HardwareDecodingAuto): | 243 | case "", string(HardwareDecodingAuto): | ||
| 214 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | 244 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | ||
| @@ -232,9 +262,6 @@ func normalizeConfig(config *Config) { | |||||
| 232 | default: | 262 | default: | ||
| 233 | config.Media.GLRenderingMode = GLRenderingModeAuto | 263 | config.Media.GLRenderingMode = GLRenderingModeAuto | ||
| 234 | } | 264 | } | ||
| 235 | - | ||||
| 236 | - | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |||
| 237 | - | normalizePerformanceProfile(config) | |||
| 238 | } | 265 | } | ||
| 239 | 266 | ||||
| 240 | func normalizePerformanceProfile(config *Config) { | 267 | func normalizePerformanceProfile(config *Config) { | ||
| @@ -247,6 +274,8 @@ func normalizePerformanceProfile(config *Config) { | |||||
| 247 | config.Performance.Profile = ProfileMax | 274 | config.Performance.Profile = ProfileMax | ||
| 248 | case string(ProfileCustom): | 275 | case string(ProfileCustom): | ||
| 249 | config.Performance.Profile = ProfileCustom | 276 | config.Performance.Profile = ProfileCustom | ||
| 277 | + | default: | |||
| 278 | + | config.Performance.Profile = ProfileDefault | |||
| 250 | } | 279 | } | ||
| 251 | } | 280 | } | ||
| 252 | 281 | ||||
| @@ -373,6 +402,7 @@ func (m *Manager) setDefaults() { | |||||
| 373 | m.setDebugDefaults(defaults) | 402 | m.setDebugDefaults(defaults) | ||
| 374 | m.setAppearanceDefaults(defaults) | 403 | m.setAppearanceDefaults(defaults) | ||
| 375 | m.setRenderingDefaults(defaults) | 404 | m.setRenderingDefaults(defaults) | ||
| 405 | + | m.setPrivacyDefaults(defaults) | |||
| 376 | m.setWorkspaceDefaults(defaults) | 406 | m.setWorkspaceDefaults(defaults) | ||
| 377 | m.setContentFilteringDefaults(defaults) | 407 | m.setContentFilteringDefaults(defaults) | ||
| 378 | m.setClipboardDefaults(defaults) | 408 | m.setClipboardDefaults(defaults) | ||
| @@ -447,6 +477,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) { | |||||
| 447 | m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale) | 477 | m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale) | ||
| 448 | } | 478 | } | ||
| 449 | 479 | ||||
| 480 | + | func (m *Manager) setPrivacyDefaults(defaults *Config) { | |||
| 481 | + | m.viper.SetDefault("privacy.cookie_policy", string(defaults.Privacy.CookiePolicy)) | |||
| 482 | + | m.viper.SetDefault("privacy.itp_enabled", defaults.Privacy.ITPEnabled) | |||
| 483 | + | } | |||
| 484 | + | ||||
| 450 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | 485 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | ||
| 451 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | 486 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | ||
| 452 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | 487 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | ||
A internal/infrastructure/config/loader_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/viper" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestSetPrivacyDefaults(t *testing.T) { | |||
| 11 | + | mgr := &Manager{viper: viper.New()} | |||
| 12 | + | mgr.setDefaults() | |||
| 13 | + | ||||
| 14 | + | assert.Equal(t, "no_third_party", mgr.viper.GetString("privacy.cookie_policy")) | |||
| 15 | + | assert.True(t, mgr.viper.GetBool("privacy.itp_enabled")) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestNormalizeConfig_PrivacyCookiePolicy(t *testing.T) { | |||
| 19 | + | cfg := DefaultConfig() | |||
| 20 | + | cfg.Privacy.CookiePolicy = CookiePolicy("INVALID") | |||
| 21 | + | ||||
| 22 | + | normalizeConfig(cfg) | |||
| 23 | + | ||||
| 24 | + | assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy) | |||
| 25 | + | } | |||
M internal/infrastructure/config/schema.go +23 -0
| @@ -27,6 +27,8 @@ type Config struct { | |||||
| 27 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | 27 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | ||
| 28 | // Rendering controls WebKit, GTK and compositor behavior. | 28 | // Rendering controls WebKit, GTK and compositor behavior. | ||
| 29 | Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"` | 29 | Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"` | ||
| 30 | + | // Privacy controls cookie acceptance and tracking prevention. | |||
| 31 | + | Privacy PrivacyConfig `mapstructure:"privacy" yaml:"privacy" toml:"privacy"` | |||
| 30 | // Media controls video playback and hardware acceleration | 32 | // Media controls video playback and hardware acceleration | ||
| 31 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | 33 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | ||
| 32 | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | 34 | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | ||
| @@ -48,6 +50,18 @@ const ( | |||||
| 48 | RenderingModeCPU RenderingMode = "cpu" | 50 | RenderingModeCPU RenderingMode = "cpu" | ||
| 49 | ) | 51 | ) | ||
| 50 | 52 | ||||
| 53 | + | // CookiePolicy controls cookie acceptance behavior. | |||
| 54 | + | type CookiePolicy string | |||
| 55 | + | ||||
| 56 | + | const ( | |||
| 57 | + | // CookiePolicyAlways accepts all cookies. | |||
| 58 | + | CookiePolicyAlways CookiePolicy = "always" | |||
| 59 | + | // CookiePolicyNoThirdParty blocks third-party cookies. | |||
| 60 | + | CookiePolicyNoThirdParty CookiePolicy = "no_third_party" | |||
| 61 | + | // CookiePolicyNever blocks all cookies. | |||
| 62 | + | CookiePolicyNever CookiePolicy = "never" | |||
| 63 | + | ) | |||
| 64 | + | ||||
| 51 | // GSKRendererMode controls the GTK Scene Kit renderer selection. | 65 | // GSKRendererMode controls the GTK Scene Kit renderer selection. | ||
| 52 | type GSKRendererMode string | 66 | type GSKRendererMode string | ||
| 53 | 67 | ||||
| @@ -103,6 +117,15 @@ type RenderingConfig struct { | |||||
| 103 | DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"` | 117 | DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"` | ||
| 104 | } | 118 | } | ||
| 105 | 119 | ||||
| 120 | + | // PrivacyConfig controls cookie behavior and tracking prevention. | |||
| 121 | + | type PrivacyConfig struct { | |||
| 122 | + | // CookiePolicy controls cookie acceptance behavior. | |||
| 123 | + | // Values: "always", "no_third_party", "never" | |||
| 124 | + | CookiePolicy CookiePolicy `mapstructure:"cookie_policy" yaml:"cookie_policy" toml:"cookie_policy"` | |||
| 125 | + | // ITPEnabled enables Intelligent Tracking Prevention. | |||
| 126 | + | ITPEnabled bool `mapstructure:"itp_enabled" yaml:"itp_enabled" toml:"itp_enabled"` | |||
| 127 | + | } | |||
| 128 | + | ||||
| 106 | // HardwareDecodingMode controls video hardware acceleration. | 129 | // HardwareDecodingMode controls video hardware acceleration. | ||
| 107 | type HardwareDecodingMode string | 130 | type HardwareDecodingMode string | ||
| 108 | 131 | ||||
M internal/infrastructure/config/validation.go +13 -0
| @@ -26,6 +26,7 @@ func validateConfig(config *Config) error { | |||||
| 26 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | 26 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | ||
| 27 | validationErrors = append(validationErrors, validateOmnibox(config)...) | 27 | validationErrors = append(validationErrors, validateOmnibox(config)...) | ||
| 28 | validationErrors = append(validationErrors, validateRendering(config)...) | 28 | validationErrors = append(validationErrors, validateRendering(config)...) | ||
| 29 | + | validationErrors = append(validationErrors, validatePrivacy(config)...) | |||
| 29 | validationErrors = append(validationErrors, validateColorScheme(config)...) | 30 | validationErrors = append(validationErrors, validateColorScheme(config)...) | ||
| 30 | validationErrors = append(validationErrors, validateSession(config)...) | 31 | validationErrors = append(validationErrors, validateSession(config)...) | ||
| 31 | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | 32 | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | ||
| @@ -323,6 +324,18 @@ func validateRendering(config *Config) []string { | |||||
| 323 | return validationErrors | 324 | return validationErrors | ||
| 324 | } | 325 | } | ||
| 325 | 326 | ||||
| 327 | + | func validatePrivacy(config *Config) []string { | |||
| 328 | + | switch config.Privacy.CookiePolicy { | |||
| 329 | + | case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "": | |||
| 330 | + | return nil | |||
| 331 | + | default: | |||
| 332 | + | return []string{fmt.Sprintf( | |||
| 333 | + | "privacy.cookie_policy must be one of: always, no_third_party, never (got: %s)", | |||
| 334 | + | config.Privacy.CookiePolicy, | |||
| 335 | + | )} | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | ||||
| 326 | func validateColorScheme(config *Config) []string { | 339 | func validateColorScheme(config *Config) []string { | ||
| 327 | switch config.Appearance.ColorScheme { | 340 | switch config.Appearance.ColorScheme { | ||
| 328 | case ThemePreferDark, ThemePreferLight, ThemeDefault, "": | 341 | case ThemePreferDark, ThemePreferLight, ThemeDefault, "": | ||
A internal/infrastructure/config/validation_test.go +36 -0
| @@ -0,0 +1,36 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | cookiePolicy CookiePolicy | |||
| 14 | + | wantErr bool | |||
| 15 | + | }{ | |||
| 16 | + | {name: "always", cookiePolicy: CookiePolicyAlways, wantErr: false}, | |||
| 17 | + | {name: "no_third_party", cookiePolicy: CookiePolicyNoThirdParty, wantErr: false}, | |||
| 18 | + | {name: "never", cookiePolicy: CookiePolicyNever, wantErr: false}, | |||
| 19 | + | {name: "invalid", cookiePolicy: CookiePolicy("bad_policy"), wantErr: true}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | for _, tt := range tests { | |||
| 23 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 24 | + | cfg := DefaultConfig() | |||
| 25 | + | cfg.Privacy.CookiePolicy = tt.cookiePolicy | |||
| 26 | + | ||||
| 27 | + | err := validateConfig(cfg) | |||
| 28 | + | if tt.wantErr { | |||
| 29 | + | require.Error(t, err) | |||
| 30 | + | assert.Contains(t, err.Error(), "privacy.cookie_policy") | |||
| 31 | + | return | |||
| 32 | + | } | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
M internal/infrastructure/filtering/downloader.go +22 -9
| @@ -183,33 +183,46 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||||
| 183 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | 183 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | - | // Write to temp file first, then atomic rename | 186 | + | // Write to temp file in cacheDir, then atomic rename into destination. |
| 187 | - | tmpPath := localPath + ".tmp" | 187 | + | tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp") |
| 188 | - | file, err := os.Create(tmpPath) | |||
| 189 | if err != nil { | 188 | if err != nil { | ||
| 190 | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | 189 | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | ||
| 191 | } | 190 | } | ||
| 191 | + | tmpPath := tmpFile.Name() | |||
| 192 | 192 | ||||
| 193 | - | bytesWritten, err := io.Copy(file, resp.Body) | 193 | + | bytesWritten, err := io.Copy(tmpFile, resp.Body) |
| 194 | if err != nil { | 194 | if err != nil { | ||
| 195 | - | _ = file.Close() | 195 | + | _ = tmpFile.Close() |
| 196 | _ = os.Remove(tmpPath) | 196 | _ = os.Remove(tmpPath) | ||
| 197 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | 197 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | ||
| 198 | } | 198 | } | ||
| 199 | - | if err := file.Close(); err != nil { | 199 | + | if err := tmpFile.Close(); err != nil { |
| 200 | _ = os.Remove(tmpPath) | 200 | _ = os.Remove(tmpPath) | ||
| 201 | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | 201 | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | ||
| 202 | } | 202 | } | ||
| 203 | 203 | ||||
| 204 | - | // Atomic rename | 204 | + | if err := d.renameTempFile(tmpPath, localPath); err != nil { |
| 205 | - | if err := os.Rename(tmpPath, localPath); err != nil { | |||
| 206 | _ = os.Remove(tmpPath) | 205 | _ = os.Remove(tmpPath) | ||
| 207 | - | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | 206 | + | return "", 0, err |
| 208 | } | 207 | } | ||
| 209 | 208 | ||||
| 210 | return localPath, bytesWritten, nil | 209 | return localPath, bytesWritten, nil | ||
| 211 | } | 210 | } | ||
| 212 | 211 | ||||
| 212 | + | func (*Downloader) renameTempFile(tmpPath, localPath string) error { | |||
| 213 | + | parentDir := filepath.Dir(localPath) | |||
| 214 | + | ||||
| 215 | + | if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil { | |||
| 216 | + | return fmt.Errorf("failed to create target dir: %w", err) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | if err := os.Rename(tmpPath, localPath); err != nil { | |||
| 220 | + | return fmt.Errorf("failed to rename temp file: %w", err) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | return nil | |||
| 224 | + | } | |||
| 225 | + | ||||
| 213 | // downloadAndCacheManifest downloads and caches the manifest. | 226 | // downloadAndCacheManifest downloads and caches the manifest. | ||
| 214 | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | 227 | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | ||
| 215 | manifest, err := d.FetchManifest(ctx) | 228 | manifest, err := d.FetchManifest(ctx) | ||
A internal/infrastructure/filtering/downloader_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package filtering | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestDownloader_DownloadFile_RecoversWhenTargetDirDeletedConcurrently(t *testing.T) { | |||
| 16 | + | t.Parallel() | |||
| 17 | + | ||||
| 18 | + | cacheDir := filepath.Join(t.TempDir(), "cache") | |||
| 19 | + | filename := filepath.Join("nested", "combined-part1.json") | |||
| 20 | + | localPath := filepath.Join(cacheDir, filename) | |||
| 21 | + | targetDir := filepath.Dir(localPath) | |||
| 22 | + | ||||
| 23 | + | require.NoError(t, os.MkdirAll(targetDir, cacheDirPerm)) | |||
| 24 | + | ||||
| 25 | + | downloadStarted := make(chan struct{}) | |||
| 26 | + | allowResponse := make(chan struct{}) | |||
| 27 | + | ||||
| 28 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 29 | + | if r.URL.Path == "/"+filename { | |||
| 30 | + | close(downloadStarted) | |||
| 31 | + | <-allowResponse | |||
| 32 | + | _, _ = io.WriteString(w, `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`) | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | http.NotFound(w, r) | |||
| 36 | + | })) | |||
| 37 | + | defer server.Close() | |||
| 38 | + | ||||
| 39 | + | d := NewDownloader(cacheDir) | |||
| 40 | + | d.baseURL = server.URL | |||
| 41 | + | ||||
| 42 | + | type result struct { | |||
| 43 | + | path string | |||
| 44 | + | n int64 | |||
| 45 | + | err error | |||
| 46 | + | } | |||
| 47 | + | resultCh := make(chan result, 1) | |||
| 48 | + | go func() { | |||
| 49 | + | path, n, err := d.downloadFile(context.Background(), filename) | |||
| 50 | + | resultCh <- result{path: path, n: n, err: err} | |||
| 51 | + | }() | |||
| 52 | + | ||||
| 53 | + | <-downloadStarted | |||
| 54 | + | require.NoError(t, os.RemoveAll(targetDir)) | |||
| 55 | + | close(allowResponse) | |||
| 56 | + | ||||
| 57 | + | res := <-resultCh | |||
| 58 | + | require.NoError(t, res.err) | |||
| 59 | + | require.Equal(t, localPath, res.path) | |||
| 60 | + | require.Positive(t, res.n) | |||
| 61 | + | ||||
| 62 | + | content, err := os.ReadFile(localPath) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.Contains(t, string(content), "url-filter") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestDownloader_RenameTempFile_RequiresDirectoryParent(t *testing.T) { | |||
| 68 | + | t.Parallel() | |||
| 69 | + | ||||
| 70 | + | cacheDir := filepath.Join(t.TempDir(), "cache") | |||
| 71 | + | require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm)) | |||
| 72 | + | ||||
| 73 | + | tmpFile := filepath.Join(cacheDir, "payload.tmp") | |||
| 74 | + | require.NoError(t, os.WriteFile(tmpFile, []byte("payload"), manifestFilePerm)) | |||
| 75 | + | ||||
| 76 | + | parentAsFile := filepath.Join(cacheDir, "target") | |||
| 77 | + | require.NoError(t, os.WriteFile(parentAsFile, []byte("not-a-directory"), manifestFilePerm)) | |||
| 78 | + | ||||
| 79 | + | d := NewDownloader(cacheDir) | |||
| 80 | + | ||||
| 81 | + | err := d.renameTempFile(tmpFile, filepath.Join(parentAsFile, "filter.json")) | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | require.Contains(t, err.Error(), "not a directory") | |||
| 84 | + | } | |||
M internal/infrastructure/filtering/manager.go +231 -71
| @@ -3,9 +3,11 @@ package filtering | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "strings" | |||
| 9 | "sync" | 11 | "sync" | ||
| 10 | "sync/atomic" | 12 | "sync/atomic" | ||
| 11 | "time" | 13 | "time" | ||
| @@ -17,12 +19,17 @@ import ( | |||||
| 17 | const ( | 19 | const ( | ||
| 18 | storeDirPerm = 0o755 | 20 | storeDirPerm = 0o755 | ||
| 19 | jsonDirPerm = 0o755 | 21 | jsonDirPerm = 0o755 | ||
| 22 | + | quarantinePerm = 0o755 | |||
| 20 | mergedFilePerm = 0o644 | 23 | mergedFilePerm = 0o644 | ||
| 21 | 24 | ||||
| 22 | // CacheMaxAge is the maximum age of the filter cache before it's considered stale. | 25 | // CacheMaxAge is the maximum age of the filter cache before it's considered stale. | ||
| 23 | CacheMaxAge = 24 * time.Hour | 26 | CacheMaxAge = 24 * time.Hour | ||
| 24 | ) | 27 | ) | ||
| 25 | 28 | ||||
| 29 | + | // ErrUpdateSkipped indicates a filter update was intentionally skipped after a recoverable | |||
| 30 | + | // update-path failure (e.g. invalid payload, merge failure, compile failure). | |||
| 31 | + | var ErrUpdateSkipped = errors.New("filter update skipped") | |||
| 32 | + | ||||
| 26 | // Manager orchestrates the content filter lifecycle. | 33 | // Manager orchestrates the content filter lifecycle. | ||
| 27 | // It handles downloading, compiling, loading, and applying filters to WebViews. | 34 | // It handles downloading, compiling, loading, and applying filters to WebViews. | ||
| 28 | type Manager struct { | 35 | type Manager struct { | ||
| @@ -140,86 +147,125 @@ func (m *Manager) LoadAsync(ctx context.Context) { | |||||
| 140 | return | 147 | return | ||
| 141 | } | 148 | } | ||
| 142 | 149 | ||||
| 143 | - | go func() { | 150 | + | go m.loadAsyncWorker(ctx) |
| 144 | - | log := logging.FromContext(ctx).With(). | 151 | + | } |
| 145 | - | Str("component", "filter-manager"). | |||
| 146 | - | Logger() | |||
| 147 | - | ||||
| 148 | - | // If already loaded, skip | |||
| 149 | - | m.filterMu.RLock() | |||
| 150 | - | if m.filter != nil { | |||
| 151 | - | m.filterMu.RUnlock() | |||
| 152 | - | log.Debug().Msg("filters already loaded, skipping async load") | |||
| 153 | - | return | |||
| 154 | - | } | |||
| 155 | - | m.filterMu.RUnlock() | |||
| 156 | - | ||||
| 157 | - | // Try to load from cache first (fast path) | |||
| 158 | - | m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."}) | |||
| 159 | - | if m.store.HasCompiledFilter(ctx, FilterIdentifier) { | |||
| 160 | - | log.Debug().Msg("found compiled filter, loading from cache") | |||
| 161 | - | filter, err := m.store.Load(ctx, FilterIdentifier) | |||
| 162 | - | if err == nil && filter != nil { | |||
| 163 | - | m.filterMu.Lock() | |||
| 164 | - | m.filter = filter | |||
| 165 | - | m.filterMu.Unlock() | |||
| 166 | - | ||||
| 167 | - | version := m.getCachedVersion() | |||
| 168 | - | m.setStatus(FilterStatus{ | |||
| 169 | - | State: StateActive, | |||
| 170 | - | Message: "Filters active", | |||
| 171 | - | Version: version, | |||
| 172 | - | }) | |||
| 173 | - | log.Info().Str("version", version).Msg("filters loaded from cache") | |||
| 174 | - | ||||
| 175 | - | m.checkStaleCacheAndUpdate(ctx) | |||
| 176 | - | return | |||
| 177 | - | } | |||
| 178 | - | log.Warn().Err(err).Msg("failed to load cached filter, will download") | |||
| 179 | - | } | |||
| 180 | 152 | ||||
| 181 | - | // Download filters | 153 | + | func (m *Manager) loadAsyncWorker(ctx context.Context) { |
| 182 | - | m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."}) | 154 | + | log := logging.FromContext(ctx).With(). |
| 183 | - | paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) { | 155 | + | Str("component", "filter-manager"). |
| 184 | - | msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total) | 156 | + | Logger() |
| 185 | - | m.setStatus(FilterStatus{State: StateLoading, Message: msg}) | |||
| 186 | - | }) | |||
| 187 | - | if err != nil { | |||
| 188 | - | log.Error().Err(err).Msg("failed to download filters") | |||
| 189 | - | m.setStatus(FilterStatus{State: StateError, Message: "Download failed"}) | |||
| 190 | - | return | |||
| 191 | - | } | |||
| 192 | 157 | ||||
| 193 | - | m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."}) | 158 | + | if m.hasActiveFilter() { |
| 159 | + | log.Debug().Msg("filters already loaded, skipping async load") | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 194 | 162 | ||||
| 195 | - | // Merge JSON files into one for compilation | 163 | + | if m.loadFromCache(ctx) { |
| 196 | - | mergedPath, err := m.mergeJSONFiles(ctx, paths) | 164 | + | m.checkStaleCacheAndUpdate(ctx) |
| 197 | - | if err != nil { | 165 | + | return |
| 198 | - | log.Error().Err(err).Msg("failed to merge filter files") | 166 | + | } |
| 199 | - | m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"}) | |||
| 200 | - | return | |||
| 201 | - | } | |||
| 202 | 167 | ||||
| 203 | - | // Compile the merged filter | 168 | + | m.downloadCompileAndActivate(ctx) |
| 204 | - | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | 169 | + | } |
| 205 | - | if err != nil { | |||
| 206 | - | log.Error().Err(err).Msg("failed to compile filters") | |||
| 207 | - | m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"}) | |||
| 208 | - | return | |||
| 209 | - | } | |||
| 210 | 170 | ||||
| 211 | - | m.filterMu.Lock() | 171 | + | func (m *Manager) loadFromCache(ctx context.Context) bool { |
| 212 | - | m.filter = filter | 172 | + | log := logging.FromContext(ctx).With(). |
| 213 | - | m.filterMu.Unlock() | 173 | + | Str("component", "filter-manager"). |
| 174 | + | Logger() | |||
| 214 | 175 | ||||
| 176 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."}) | |||
| 177 | + | if !m.store.HasCompiledFilter(ctx, FilterIdentifier) { | |||
| 178 | + | return false | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | log.Debug().Msg("found compiled filter, loading from cache") | |||
| 182 | + | filter, err := m.store.Load(ctx, FilterIdentifier) | |||
| 183 | + | if err != nil || filter == nil { | |||
| 184 | + | log.Warn().Err(err).Msg("failed to load cached filter, will download") | |||
| 185 | + | return false | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | version := m.setActiveFilter(filter, "Filters active") | |||
| 189 | + | log.Info().Str("version", version).Msg("filters loaded from cache") | |||
| 190 | + | return true | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (m *Manager) downloadCompileAndActivate(ctx context.Context) { | |||
| 194 | + | log := logging.FromContext(ctx).With(). | |||
| 195 | + | Str("component", "filter-manager"). | |||
| 196 | + | Logger() | |||
| 197 | + | ||||
| 198 | + | paths, err := m.downloadFiltersWithProgress(ctx) | |||
| 199 | + | if err != nil { | |||
| 200 | + | log.Error().Err(err).Msg("failed to download filters") | |||
| 201 | + | m.setStatus(FilterStatus{State: StateError, Message: "Download failed"}) | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | validateErr := m.validateDownloadedFiles(ctx, paths) | |||
| 206 | + | if validateErr != nil { | |||
| 207 | + | log.Error().Err(validateErr).Msg("downloaded filter validation failed") | |||
| 208 | + | m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"}) | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."}) | |||
| 213 | + | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |||
| 214 | + | if err != nil { | |||
| 215 | + | log.Error().Err(err).Msg("failed to merge filter files") | |||
| 216 | + | m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"}) | |||
| 217 | + | return | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |||
| 221 | + | if err != nil { | |||
| 222 | + | log.Error().Err(err).Msg("failed to compile filters") | |||
| 223 | + | m.handleCompilationFailure() | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | if filter == nil { | |||
| 227 | + | log.Error().Msg("filter compilation returned nil filter") | |||
| 228 | + | m.handleCompilationFailure() | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | version := m.setActiveFilter(filter, "Filters active") | |||
| 233 | + | log.Info().Str("version", version).Msg("filters compiled and active") | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) { | |||
| 237 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."}) | |||
| 238 | + | return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) { | |||
| 239 | + | msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total) | |||
| 240 | + | m.setStatus(FilterStatus{State: StateLoading, Message: msg}) | |||
| 241 | + | }) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func (m *Manager) handleCompilationFailure() { | |||
| 245 | + | if m.hasActiveFilter() { | |||
| 215 | version := m.getCachedVersion() | 246 | version := m.getCachedVersion() | ||
| 216 | m.setStatus(FilterStatus{ | 247 | m.setStatus(FilterStatus{ | ||
| 217 | State: StateActive, | 248 | State: StateActive, | ||
| 218 | - | Message: "Filters active", | 249 | + | Message: "Filters active (update skipped)", |
| 219 | Version: version, | 250 | Version: version, | ||
| 220 | }) | 251 | }) | ||
| 221 | - | log.Info().Str("version", version).Msg("filters compiled and active") | 252 | + | return |
| 222 | - | }() | 253 | + | } |
| 254 | + | m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"}) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message string) string { | |||
| 258 | + | m.filterMu.Lock() | |||
| 259 | + | m.filter = filter | |||
| 260 | + | m.filterMu.Unlock() | |||
| 261 | + | ||||
| 262 | + | version := m.getCachedVersion() | |||
| 263 | + | m.setStatus(FilterStatus{ | |||
| 264 | + | State: StateActive, | |||
| 265 | + | Message: message, | |||
| 266 | + | Version: version, | |||
| 267 | + | }) | |||
| 268 | + | return version | |||
| 223 | } | 269 | } | ||
| 224 | 270 | ||||
| 225 | // mergeJSONFiles combines multiple JSON rule files into one. | 271 | // mergeJSONFiles combines multiple JSON rule files into one. | ||
| @@ -345,16 +391,27 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||||
| 345 | if err != nil { | 391 | if err != nil { | ||
| 346 | return fmt.Errorf("failed to download filter update: %w", err) | 392 | return fmt.Errorf("failed to download filter update: %w", err) | ||
| 347 | } | 393 | } | ||
| 394 | + | validateErr := m.validateDownloadedFiles(ctx, paths) | |||
| 395 | + | if validateErr != nil { | |||
| 396 | + | log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter") | |||
| 397 | + | return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr) | |||
| 398 | + | } | |||
| 348 | 399 | ||||
| 349 | // Merge and compile | 400 | // Merge and compile | ||
| 350 | mergedPath, err := m.mergeJSONFiles(ctx, paths) | 401 | mergedPath, err := m.mergeJSONFiles(ctx, paths) | ||
| 351 | if err != nil { | 402 | if err != nil { | ||
| 352 | - | return fmt.Errorf("failed to merge updated filters: %w", err) | 403 | + | log.Warn().Err(err).Msg("failed to merge updated filters, keeping existing active filter") |
| 404 | + | return fmt.Errorf("%w: merge failed: %w", ErrUpdateSkipped, err) | |||
| 353 | } | 405 | } | ||
| 354 | 406 | ||||
| 355 | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | 407 | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | ||
| 356 | if err != nil { | 408 | if err != nil { | ||
| 357 | - | return fmt.Errorf("failed to compile updated filters: %w", err) | 409 | + | log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter") |
| 410 | + | return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err) | |||
| 411 | + | } | |||
| 412 | + | if filter == nil { | |||
| 413 | + | log.Warn().Msg("updated filter compilation returned nil filter, keeping existing active filter") | |||
| 414 | + | return fmt.Errorf("%w: compile returned nil filter", ErrUpdateSkipped) | |||
| 358 | } | 415 | } | ||
| 359 | 416 | ||||
| 360 | m.filterMu.Lock() | 417 | m.filterMu.Lock() | ||
| @@ -372,6 +429,109 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||||
| 372 | return nil | 429 | return nil | ||
| 373 | } | 430 | } | ||
| 374 | 431 | ||||
| 432 | + | func (m *Manager) hasActiveFilter() bool { | |||
| 433 | + | m.filterMu.RLock() | |||
| 434 | + | defer m.filterMu.RUnlock() | |||
| 435 | + | return m.filter != nil | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error { | |||
| 439 | + | var validationErrs []error | |||
| 440 | + | var quarantineErrs []error | |||
| 441 | + | ||||
| 442 | + | for _, path := range paths { | |||
| 443 | + | if err := validateRuleFile(path); err != nil { | |||
| 444 | + | if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil { | |||
| 445 | + | quarantineErrs = append(quarantineErrs, fmt.Errorf("file=%s: %w", path, quarantineErr)) | |||
| 446 | + | } | |||
| 447 | + | validationErrs = append(validationErrs, fmt.Errorf("file=%s: %w", path, err)) | |||
| 448 | + | } | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | if len(validationErrs) == 0 && len(quarantineErrs) == 0 { | |||
| 452 | + | return nil | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | var parts []string | |||
| 456 | + | if len(validationErrs) > 0 { | |||
| 457 | + | parts = append(parts, fmt.Sprintf("%d invalid file(s)", len(validationErrs))) | |||
| 458 | + | } | |||
| 459 | + | if len(quarantineErrs) > 0 { | |||
| 460 | + | parts = append(parts, fmt.Sprintf("%d quarantine failure(s)", len(quarantineErrs))) | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | return fmt.Errorf( | |||
| 464 | + | "downloaded filter validation failed (%s): %w", | |||
| 465 | + | strings.Join(parts, ", "), | |||
| 466 | + | errors.Join(append(validationErrs, quarantineErrs...)...), | |||
| 467 | + | ) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func validateRuleFile(path string) error { | |||
| 471 | + | data, err := os.ReadFile(path) | |||
| 472 | + | if err != nil { | |||
| 473 | + | return fmt.Errorf("read failed: %w", err) | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | var rules []map[string]json.RawMessage | |||
| 477 | + | if err := json.Unmarshal(data, &rules); err != nil { | |||
| 478 | + | return fmt.Errorf("json parse failed: %w", err) | |||
| 479 | + | } | |||
| 480 | + | if len(rules) == 0 { | |||
| 481 | + | return fmt.Errorf("empty rule set") | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | for i, rule := range rules { | |||
| 485 | + | trigger, ok := rule["trigger"] | |||
| 486 | + | if !ok || len(trigger) == 0 || string(trigger) == "null" { | |||
| 487 | + | return fmt.Errorf("rule[%d] missing trigger", i) | |||
| 488 | + | } | |||
| 489 | + | action, ok := rule["action"] | |||
| 490 | + | if !ok || len(action) == 0 || string(action) == "null" { | |||
| 491 | + | return fmt.Errorf("rule[%d] missing action", i) | |||
| 492 | + | } | |||
| 493 | + | var actionObj struct { | |||
| 494 | + | Type string `json:"type"` | |||
| 495 | + | } | |||
| 496 | + | if err := json.Unmarshal(action, &actionObj); err != nil { | |||
| 497 | + | return fmt.Errorf("rule[%d] action parse failed: %w", i, err) | |||
| 498 | + | } | |||
| 499 | + | if actionObj.Type == "" { | |||
| 500 | + | return fmt.Errorf("rule[%d] action.type missing", i) | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | return nil | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error { | |||
| 508 | + | log := logging.FromContext(ctx).With(). | |||
| 509 | + | Str("component", "filter-manager"). | |||
| 510 | + | Logger() | |||
| 511 | + | ||||
| 512 | + | quarantineDir := filepath.Join(m.jsonDir, "quarantine") | |||
| 513 | + | if err := os.MkdirAll(quarantineDir, quarantinePerm); err != nil { | |||
| 514 | + | return fmt.Errorf("create quarantine dir failed: %w", err) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | baseName := filepath.Base(srcPath) | |||
| 518 | + | quarantinePath := filepath.Join( | |||
| 519 | + | quarantineDir, | |||
| 520 | + | fmt.Sprintf("%d-%s", time.Now().UnixNano(), baseName), | |||
| 521 | + | ) | |||
| 522 | + | if err := os.Rename(srcPath, quarantinePath); err != nil { | |||
| 523 | + | return fmt.Errorf("move to quarantine failed: %w", err) | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | log.Error(). | |||
| 527 | + | Str("path", srcPath). | |||
| 528 | + | Str("quarantine_path", quarantinePath). | |||
| 529 | + | Str("cause", cause). | |||
| 530 | + | Msg("quarantined invalid filter file") | |||
| 531 | + | ||||
| 532 | + | return nil | |||
| 533 | + | } | |||
| 534 | + | ||||
| 375 | // Enable enables content filtering. | 535 | // Enable enables content filtering. | ||
| 376 | func (m *Manager) Enable(ctx context.Context) { | 536 | func (m *Manager) Enable(ctx context.Context) { | ||
| 377 | log := logging.FromContext(ctx).With(). | 537 | log := logging.FromContext(ctx).With(). | ||
M internal/infrastructure/filtering/manager_test.go +117 -4
| @@ -2,6 +2,7 @@ package filtering_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "sync" | 8 | "sync" | ||
| @@ -11,6 +12,7 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 12 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | 13 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | ||
| 13 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 15 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 14 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/mock" | 17 | "github.com/stretchr/testify/mock" | ||
| 16 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| @@ -54,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) { | |||||
| 54 | 56 | ||||
| 55 | mockStore.EXPECT(). | 57 | mockStore.EXPECT(). | ||
| 56 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | 58 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | ||
| 57 | - | Return(nil, nil) | 59 | + | Return(&webkit.UserContentFilter{}, nil) |
| 58 | 60 | ||||
| 59 | mockDownloader.EXPECT(). | 61 | mockDownloader.EXPECT(). | ||
| 60 | GetCachedManifest(). | 62 | GetCachedManifest(). | ||
| @@ -144,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||||
| 144 | // Compile should be called | 146 | // Compile should be called | ||
| 145 | mockStore.EXPECT(). | 147 | mockStore.EXPECT(). | ||
| 146 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | 148 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | ||
| 147 | - | Return(nil, nil) | 149 | + | Return(&webkit.UserContentFilter{}, nil) |
| 148 | 150 | ||||
| 149 | // GetCachedManifest for version | 151 | // GetCachedManifest for version | ||
| 150 | mockDownloader.EXPECT(). | 152 | mockDownloader.EXPECT(). | ||
| @@ -241,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) | |||||
| 241 | // Compile called | 243 | // Compile called | ||
| 242 | mockStore.EXPECT(). | 244 | mockStore.EXPECT(). | ||
| 243 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | 245 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | ||
| 244 | - | Return(nil, nil) | 246 | + | Return(&webkit.UserContentFilter{}, nil) |
| 245 | 247 | ||||
| 246 | // GetCachedManifest for version | 248 | // GetCachedManifest for version | ||
| 247 | mockDownloader.EXPECT(). | 249 | mockDownloader.EXPECT(). | ||
| @@ -451,7 +453,7 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||||
| 451 | Return([]string{jsonFile}, nil) | 453 | Return([]string{jsonFile}, nil) | ||
| 452 | mockStore.EXPECT(). | 454 | mockStore.EXPECT(). | ||
| 453 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | 455 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | ||
| 454 | - | Return(nil, nil) | 456 | + | Return(&webkit.UserContentFilter{}, nil) |
| 455 | mockDownloader.EXPECT(). | 457 | mockDownloader.EXPECT(). | ||
| 456 | GetCachedManifest(). | 458 | GetCachedManifest(). | ||
| 457 | Return(&filtering.Manifest{Version: "test"}, nil) | 459 | Return(&filtering.Manifest{Version: "test"}, nil) | ||
| @@ -494,3 +496,114 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||||
| 494 | defer mu.Unlock() | 496 | defer mu.Unlock() | ||
| 495 | assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice") | 497 | assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice") | ||
| 496 | } | 498 | } | ||
| 499 | + | ||||
| 500 | + | func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) { | |||
| 501 | + | tmpDir := t.TempDir() | |||
| 502 | + | storeDir := filepath.Join(tmpDir, "store") | |||
| 503 | + | jsonDir := filepath.Join(tmpDir, "json") | |||
| 504 | + | ||||
| 505 | + | invalidJSONFile := filepath.Join(jsonDir, "combined-part1.json") | |||
| 506 | + | require.NoError(t, os.MkdirAll(jsonDir, 0o755)) | |||
| 507 | + | require.NoError(t, os.WriteFile(invalidJSONFile, []byte(`{"invalid":`), 0o644)) | |||
| 508 | + | ||||
| 509 | + | mockStore := mocks.NewMockFilterStore(t) | |||
| 510 | + | mockDownloader := mocks.NewMockFilterDownloader(t) | |||
| 511 | + | ||||
| 512 | + | mockStore.EXPECT(). | |||
| 513 | + | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |||
| 514 | + | Return(false) | |||
| 515 | + | ||||
| 516 | + | mockDownloader.EXPECT(). | |||
| 517 | + | DownloadFilters(mock.Anything, mock.Anything). | |||
| 518 | + | Return([]string{invalidJSONFile}, nil) | |||
| 519 | + | ||||
| 520 | + | mgr, err := filtering.NewManager(filtering.ManagerConfig{ | |||
| 521 | + | StoreDir: storeDir, | |||
| 522 | + | JSONDir: jsonDir, | |||
| 523 | + | Enabled: true, | |||
| 524 | + | AutoUpdate: false, | |||
| 525 | + | Store: mockStore, | |||
| 526 | + | Downloader: mockDownloader, | |||
| 527 | + | }) | |||
| 528 | + | require.NoError(t, err) | |||
| 529 | + | ||||
| 530 | + | ctx := testContext() | |||
| 531 | + | require.NoError(t, mgr.Initialize(ctx)) | |||
| 532 | + | ||||
| 533 | + | mgr.LoadAsync(ctx) | |||
| 534 | + | ||||
| 535 | + | require.Eventually(t, func() bool { | |||
| 536 | + | return mgr.Status().State == filtering.StateError | |||
| 537 | + | }, 3*time.Second, 50*time.Millisecond) | |||
| 538 | + | assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message) | |||
| 539 | + | ||||
| 540 | + | _, statErr := os.Stat(invalidJSONFile) | |||
| 541 | + | require.ErrorIs(t, statErr, os.ErrNotExist) | |||
| 542 | + | ||||
| 543 | + | quarantineDir := filepath.Join(jsonDir, "quarantine") | |||
| 544 | + | entries, err := os.ReadDir(quarantineDir) | |||
| 545 | + | require.NoError(t, err) | |||
| 546 | + | require.Len(t, entries, 1) | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) { | |||
| 550 | + | tmpDir := t.TempDir() | |||
| 551 | + | storeDir := filepath.Join(tmpDir, "store") | |||
| 552 | + | jsonDir := filepath.Join(tmpDir, "json") | |||
| 553 | + | ||||
| 554 | + | validJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]` | |||
| 555 | + | jsonFile := filepath.Join(jsonDir, "combined-part1.json") | |||
| 556 | + | require.NoError(t, os.MkdirAll(jsonDir, 0o755)) | |||
| 557 | + | require.NoError(t, os.WriteFile(jsonFile, []byte(validJSON), 0o644)) | |||
| 558 | + | ||||
| 559 | + | mockStore := mocks.NewMockFilterStore(t) | |||
| 560 | + | mockDownloader := mocks.NewMockFilterDownloader(t) | |||
| 561 | + | ||||
| 562 | + | activeFilter := &webkit.UserContentFilter{} | |||
| 563 | + | ||||
| 564 | + | mockStore.EXPECT(). | |||
| 565 | + | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |||
| 566 | + | Return(true).Once() | |||
| 567 | + | mockStore.EXPECT(). | |||
| 568 | + | Load(mock.Anything, filtering.FilterIdentifier). | |||
| 569 | + | Return(activeFilter, nil).Once() | |||
| 570 | + | mockDownloader.EXPECT(). | |||
| 571 | + | GetCachedManifest(). | |||
| 572 | + | Return(&filtering.Manifest{Version: "2025.12.19"}, nil).Once() | |||
| 573 | + | mockDownloader.EXPECT(). | |||
| 574 | + | IsCacheStale(filtering.CacheMaxAge). | |||
| 575 | + | Return(false).Once() | |||
| 576 | + | ||||
| 577 | + | mockDownloader.EXPECT(). | |||
| 578 | + | NeedsUpdate(mock.Anything). | |||
| 579 | + | Return(true, nil).Once() | |||
| 580 | + | mockDownloader.EXPECT(). | |||
| 581 | + | DownloadFilters(mock.Anything, mock.Anything). | |||
| 582 | + | Return([]string{jsonFile}, nil).Once() | |||
| 583 | + | mockStore.EXPECT(). | |||
| 584 | + | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |||
| 585 | + | Return(nil, errors.New("compile failed")).Once() | |||
| 586 | + | ||||
| 587 | + | mgr, err := filtering.NewManager(filtering.ManagerConfig{ | |||
| 588 | + | StoreDir: storeDir, | |||
| 589 | + | JSONDir: jsonDir, | |||
| 590 | + | Enabled: true, | |||
| 591 | + | AutoUpdate: true, | |||
| 592 | + | Store: mockStore, | |||
| 593 | + | Downloader: mockDownloader, | |||
| 594 | + | }) | |||
| 595 | + | require.NoError(t, err) | |||
| 596 | + | ||||
| 597 | + | ctx := testContext() | |||
| 598 | + | require.NoError(t, mgr.Initialize(ctx)) | |||
| 599 | + | mgr.LoadAsync(ctx) | |||
| 600 | + | ||||
| 601 | + | require.Eventually(t, func() bool { | |||
| 602 | + | return mgr.GetFilter() != nil && mgr.Status().State == filtering.StateActive | |||
| 603 | + | }, 3*time.Second, 50*time.Millisecond) | |||
| 604 | + | ||||
| 605 | + | err = mgr.CheckForUpdates(ctx) | |||
| 606 | + | require.ErrorIs(t, err, filtering.ErrUpdateSkipped) | |||
| 607 | + | assert.Same(t, activeFilter, mgr.GetFilter()) | |||
| 608 | + | assert.Equal(t, filtering.StateActive, mgr.Status().State) | |||
| 609 | + | } | |||
M internal/infrastructure/persistence/sqlite/history_repo.go +85 -26
| @@ -6,7 +6,9 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | + | "sync/atomic" | |||
| 9 | "time" | 10 | "time" | ||
| 11 | + | "unicode" | |||
| 10 | 12 | ||||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | "github.com/bnema/dumber/internal/domain/repository" | 14 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -16,6 +18,8 @@ import ( | |||||
| 16 | 18 | ||||
| 17 | const logURLMaxLen = 60 | 19 | const logURLMaxLen = 60 | ||
| 18 | 20 | ||||
| 21 | + | var expectedFTSFailureCount atomic.Uint64 | |||
| 22 | + | ||||
| 19 | type historyRepo struct { | 23 | type historyRepo struct { | ||
| 20 | queries *sqlc.Queries | 24 | queries *sqlc.Queries | ||
| 21 | } | 25 | } | ||
| @@ -68,28 +72,15 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor | |||||
| 68 | } | 72 | } | ||
| 69 | 73 | ||||
| 70 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | 74 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | ||
| 71 | - | log := logging.FromContext(ctx) | 75 | + | words := sanitizeFTS5QueryTokens(query) |
| 72 | - | ||||
| 73 | - | // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators | |||
| 74 | - | // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens | |||
| 75 | - | // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms | |||
| 76 | - | normalizedQuery := strings.NewReplacer( | |||
| 77 | - | ".", " ", | |||
| 78 | - | "/", " ", | |||
| 79 | - | ).Replace(query) | |||
| 80 | - | ||||
| 81 | - | // Split query into words and add prefix matching to each | |||
| 82 | - | // This enables multi-word searches like "github issues" -> "github* issues*" | |||
| 83 | - | words := strings.Fields(normalizedQuery) | |||
| 84 | if len(words) == 0 { | 76 | if len(words) == 0 { | ||
| 85 | return []entity.HistoryMatch{}, nil | 77 | return []entity.HistoryMatch{}, nil | ||
| 86 | } | 78 | } | ||
| 87 | 79 | ||||
| 88 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | 80 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | ||
| 89 | - | // Sanitize each word to remove FTS5 special characters | |||
| 90 | parts := make([]string, len(words)) | 81 | parts := make([]string, len(words)) | ||
| 91 | for i, word := range words { | 82 | for i, word := range words { | ||
| 92 | - | parts[i] = sanitizeFTS5Word(word) + "*" | 83 | + | parts[i] = word + "*" |
| 93 | } | 84 | } | ||
| 94 | ftsQuery := strings.Join(parts, " ") | 85 | ftsQuery := strings.Join(parts, " ") | ||
| 95 | 86 | ||||
| @@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 97 | // Use domain-boosted query for URL search to prioritize domain matches | 88 | // Use domain-boosted query for URL search to prioritize domain matches | ||
| 98 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | 89 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | ||
| 99 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | 90 | urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{ | ||
| 100 | - | Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true}, | 91 | + | Term: sql.NullString{String: words[0], Valid: true}, |
| 101 | Query: ftsQuery, | 92 | Query: ftsQuery, | ||
| 102 | Limit: int64(limit), | 93 | Limit: int64(limit), | ||
| 103 | }) | 94 | }) | ||
| 104 | if urlErr != nil { | 95 | if urlErr != nil { | ||
| 105 | - | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") | 96 | + | logFTSSearchError(ctx, "url", query, urlErr) |
| 106 | urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{} | 97 | urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{} | ||
| 107 | } | 98 | } | ||
| 108 | 99 | ||||
| @@ -111,7 +102,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 111 | Limit: int64(limit), | 102 | Limit: int64(limit), | ||
| 112 | }) | 103 | }) | ||
| 113 | if titleErr != nil { | 104 | if titleErr != nil { | ||
| 114 | - | log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed") | 105 | + | logFTSSearchError(ctx, "title", query, titleErr) |
| 115 | titleRows = []sqlc.History{} | 106 | titleRows = []sqlc.History{} | ||
| 116 | } | 107 | } | ||
| 117 | 108 | ||||
| @@ -163,23 +154,77 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) * | |||||
| 163 | } | 154 | } | ||
| 164 | } | 155 | } | ||
| 165 | 156 | ||||
| 166 | - | // sanitizeFTS5Word removes FTS5 special characters from a search word. | 157 | + | // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens. |
| 167 | - | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | 158 | + | func sanitizeFTS5QueryTokens(query string) []string { |
| 168 | - | // Note: Periods are handled earlier by replacing with spaces (tokenizer separator) | 159 | + | normalized := normalizeFTSSeparators(query) |
| 160 | + | rawTokens := strings.Fields(normalized) | |||
| 161 | + | if len(rawTokens) == 0 { | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | tokens := make([]string, 0, len(rawTokens)) | |||
| 166 | + | for _, token := range rawTokens { | |||
| 167 | + | sanitized := sanitizeFTS5Word(token) | |||
| 168 | + | if sanitized == "" { | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | upper := strings.ToUpper(sanitized) | |||
| 172 | + | if upper == "AND" || upper == "OR" || upper == "NOT" || upper == "NEAR" { | |||
| 173 | + | continue | |||
| 174 | + | } | |||
| 175 | + | tokens = append(tokens, sanitized) | |||
| 176 | + | } | |||
| 177 | + | return tokens | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // normalizeFTSSeparators replaces non-alphanumeric separators with spaces. | |||
| 181 | + | func normalizeFTSSeparators(s string) string { | |||
| 182 | + | var b strings.Builder | |||
| 183 | + | b.Grow(len(s)) | |||
| 184 | + | for _, r := range s { | |||
| 185 | + | if unicode.IsLetter(r) || unicode.IsNumber(r) { | |||
| 186 | + | b.WriteRune(r) | |||
| 187 | + | } else { | |||
| 188 | + | b.WriteByte(' ') | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | return b.String() | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // sanitizeFTS5Word keeps only alphanumeric runes in a single token. | |||
| 169 | func sanitizeFTS5Word(word string) string { | 195 | func sanitizeFTS5Word(word string) string { | ||
| 170 | - | // Remove characters that have special meaning in FTS5 | |||
| 171 | var result strings.Builder | 196 | var result strings.Builder | ||
| 197 | + | result.Grow(len(word)) | |||
| 172 | for _, r := range word { | 198 | for _, r := range word { | ||
| 173 | - | switch r { | 199 | + | if unicode.IsLetter(r) || unicode.IsNumber(r) { |
| 174 | - | case '"', '*', '(', ')', ':', '^', '-', '/': | |||
| 175 | - | // Skip FTS5 special characters | |||
| 176 | - | default: | |||
| 177 | result.WriteRune(r) | 200 | result.WriteRune(r) | ||
| 178 | } | 201 | } | ||
| 179 | } | 202 | } | ||
| 180 | return result.String() | 203 | return result.String() | ||
| 181 | } | 204 | } | ||
| 182 | 205 | ||||
| 206 | + | func logFTSSearchError(ctx context.Context, part, query string, err error) { | |||
| 207 | + | log := logging.FromContext(ctx) | |||
| 208 | + | if isExpectedFTSError(err) { | |||
| 209 | + | count := expectedFTSFailureCount.Add(1) | |||
| 210 | + | if count == 1 || count%100 == 0 { | |||
| 211 | + | log.Debug(). | |||
| 212 | + | Uint64("expected_error_count", count). | |||
| 213 | + | Str("part", part). | |||
| 214 | + | Msg("expected FTS errors observed") | |||
| 215 | + | } | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | log.Debug().Err(err).Str("part", part).Str("query", query).Msg("FTS search failed") | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func isExpectedFTSError(err error) bool { | |||
| 222 | + | msg := strings.ToLower(err.Error()) | |||
| 223 | + | return strings.Contains(msg, "malformed match") || | |||
| 224 | + | strings.Contains(msg, "fts5: syntax error") || | |||
| 225 | + | strings.Contains(msg, "unterminated string") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 183 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 228 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 184 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | 229 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | ||
| 185 | Limit: int64(limit), | 230 | Limit: int64(limit), | ||
| @@ -266,6 +311,20 @@ func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error | |||||
| 266 | return r.queries.IncrementVisitCount(ctx, url) | 311 | return r.queries.IncrementVisitCount(ctx, url) | ||
| 267 | } | 312 | } | ||
| 268 | 313 | ||||
| 314 | + | func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error { | |||
| 315 | + | // Skip incrementing about:blank - it should always have visit_count = 1 | |||
| 316 | + | if url == aboutBlankURL { | |||
| 317 | + | return nil | |||
| 318 | + | } | |||
| 319 | + | if delta <= 0 { | |||
| 320 | + | return fmt.Errorf("visit delta must be > 0, got %d", delta) | |||
| 321 | + | } | |||
| 322 | + | return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{ | |||
| 323 | + | VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true}, | |||
| 324 | + | Url: url, | |||
| 325 | + | }) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 269 | func (r *historyRepo) Delete(ctx context.Context, id int64) error { | 328 | func (r *historyRepo) Delete(ctx context.Context, id int64) error { | ||
| 270 | return r.queries.DeleteHistoryByID(ctx, id) | 329 | return r.queries.DeleteHistoryByID(ctx, id) | ||
| 271 | } | 330 | } | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +50 -0
| @@ -429,6 +429,35 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) { | |||||
| 429 | assert.Empty(t, results) | 429 | assert.Empty(t, results) | ||
| 430 | } | 430 | } | ||
| 431 | 431 | ||||
| 432 | + | func TestHistoryRepository_Search_NoValidTokens(t *testing.T) { | |||
| 433 | + | ctx := historyTestCtx() | |||
| 434 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 435 | + | ||||
| 436 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 437 | + | require.NoError(t, err) | |||
| 438 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 439 | + | ||||
| 440 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 441 | + | ||||
| 442 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 443 | + | URL: "https://example.com", | |||
| 444 | + | Title: "Example", | |||
| 445 | + | })) | |||
| 446 | + | ||||
| 447 | + | invalidQueries := []string{ | |||
| 448 | + | "!!!", | |||
| 449 | + | "___", | |||
| 450 | + | "AND OR NOT NEAR", | |||
| 451 | + | `"***"`, | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | for _, q := range invalidQueries { | |||
| 455 | + | results, err := repo.Search(ctx, q, 10) | |||
| 456 | + | require.NoError(t, err, "query %q should return early without SQL errors", q) | |||
| 457 | + | assert.Empty(t, results, "query %q should have no valid tokens", q) | |||
| 458 | + | } | |||
| 459 | + | } | |||
| 460 | + | ||||
| 432 | func TestHistoryRepository_Search_NoResults(t *testing.T) { | 461 | func TestHistoryRepository_Search_NoResults(t *testing.T) { | ||
| 433 | ctx := historyTestCtx() | 462 | ctx := historyTestCtx() | ||
| 434 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | 463 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | ||
| @@ -557,6 +586,27 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) { | |||||
| 557 | } | 586 | } | ||
| 558 | } | 587 | } | ||
| 559 | 588 | ||||
| 589 | + | func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) { | |||
| 590 | + | ctx := historyTestCtx() | |||
| 591 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 592 | + | ||||
| 593 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 594 | + | require.NoError(t, err) | |||
| 595 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 596 | + | ||||
| 597 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 598 | + | ||||
| 599 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{ | |||
| 600 | + | URL: "https://github.com/bnema/dumber", | |||
| 601 | + | Title: "Dumber", | |||
| 602 | + | })) | |||
| 603 | + | ||||
| 604 | + | results, err := repo.Search(ctx, "AND github OR", 10) | |||
| 605 | + | require.NoError(t, err) | |||
| 606 | + | require.Len(t, results, 1) | |||
| 607 | + | assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL) | |||
| 608 | + | } | |||
| 609 | + | ||||
| 560 | func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | 610 | func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | ||
| 561 | ctx := historyTestCtx() | 611 | ctx := historyTestCtx() | ||
| 562 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | 612 | dbPath := filepath.Join(t.TempDir(), "dumber.db") | ||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +26 -0
| @@ -18,6 +18,7 @@ package sqlite | |||||
| 18 | import ( | 18 | import ( | ||
| 19 | "context" | 19 | "context" | ||
| 20 | "database/sql" | 20 | "database/sql" | ||
| 21 | + | "fmt" | |||
| 21 | "sync" | 22 | "sync" | ||
| 22 | "time" | 23 | "time" | ||
| 23 | 24 | ||||
| @@ -114,6 +115,31 @@ func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url str | |||||
| 114 | return r.repo.IncrementVisitCount(ctx, url) | 115 | return r.repo.IncrementVisitCount(ctx, url) | ||
| 115 | } | 116 | } | ||
| 116 | 117 | ||||
| 118 | + | func (r *LazyHistoryRepository) IncrementVisitCountBy(ctx context.Context, url string, delta int) error { | |||
| 119 | + | if delta <= 0 { | |||
| 120 | + | return fmt.Errorf("visit delta must be > 0, got %d", delta) | |||
| 121 | + | } | |||
| 122 | + | if err := r.init(ctx); err != nil { | |||
| 123 | + | return err | |||
| 124 | + | } | |||
| 125 | + | if deltaRepo, ok := r.repo.(interface { | |||
| 126 | + | IncrementVisitCountBy(context.Context, string, int) error | |||
| 127 | + | }); ok { | |||
| 128 | + | return deltaRepo.IncrementVisitCountBy(ctx, url, delta) | |||
| 129 | + | } | |||
| 130 | + | // Fallback: single-increment loop for repos that don't support delta increment. | |||
| 131 | + | const fallbackCap = 256 | |||
| 132 | + | if delta > fallbackCap { | |||
| 133 | + | return fmt.Errorf("visit delta %d exceeds fallback cap %d and repo does not support IncrementVisitCountBy", delta, fallbackCap) | |||
| 134 | + | } | |||
| 135 | + | for i := 0; i < delta; i++ { | |||
| 136 | + | if err := r.repo.IncrementVisitCount(ctx, url); err != nil { | |||
| 137 | + | return err | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 117 | func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error { | 143 | func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error { | ||
| 118 | if err := r.init(ctx); err != nil { | 144 | if err := r.init(ctx); err != nil { | ||
| 119 | return err | 145 | return err | ||
M internal/infrastructure/persistence/sqlite/queries/history.sql +5 -0
| @@ -19,6 +19,11 @@ UPDATE history | |||||
| 19 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | 19 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | ||
| 20 | WHERE url = ?; | 20 | WHERE url = ?; | ||
| 21 | 21 | ||||
| 22 | + | -- name: IncrementVisitCountByDelta :exec | |||
| 23 | + | UPDATE history | |||
| 24 | + | SET visit_count = visit_count + ?, last_visited = CURRENT_TIMESTAMP | |||
| 25 | + | WHERE url = ?; | |||
| 26 | + | ||||
| 22 | -- name: SearchHistory :many | 27 | -- name: SearchHistory :many | ||
| 23 | SELECT * FROM history | 28 | SELECT * FROM history | ||
| 24 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | 29 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | ||
M internal/infrastructure/persistence/sqlite/session_state_repo.go +27 -3
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | + | "fmt" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | "github.com/bnema/dumber/internal/domain/repository" | 11 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -13,12 +14,16 @@ import ( | |||||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | type sessionStateRepo struct { | 16 | type sessionStateRepo struct { | ||
| 17 | + | db *sql.DB | |||
| 16 | queries *sqlc.Queries | 18 | queries *sqlc.Queries | ||
| 17 | } | 19 | } | ||
| 18 | 20 | ||||
| 19 | // NewSessionStateRepository creates a new session state repository. | 21 | // NewSessionStateRepository creates a new session state repository. | ||
| 20 | func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository { | 22 | func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository { | ||
| 21 | - | return &sessionStateRepo{queries: sqlc.New(db)} | 23 | + | return &sessionStateRepo{ |
| 24 | + | db: db, | |||
| 25 | + | queries: sqlc.New(db), | |||
| 26 | + | } | |||
| 22 | } | 27 | } | ||
| 23 | 28 | ||||
| 24 | // SaveSnapshot saves or updates a session state snapshot. | 29 | // SaveSnapshot saves or updates a session state snapshot. | ||
| @@ -40,14 +45,33 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi | |||||
| 40 | Int("pane_count", state.CountPanes()). | 45 | Int("pane_count", state.CountPanes()). | ||
| 41 | Msg("saving session state snapshot") | 46 | Msg("saving session state snapshot") | ||
| 42 | 47 | ||||
| 43 | - | return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | 48 | + | tx, err := r.db.BeginTx(ctx, nil) |
| 49 | + | if err != nil { | |||
| 50 | + | return fmt.Errorf("begin snapshot transaction: %w", err) | |||
| 51 | + | } | |||
| 52 | + | defer func() { | |||
| 53 | + | if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { | |||
| 54 | + | log.Debug().Err(rollbackErr).Msg("snapshot rollback reported non-terminal error") | |||
| 55 | + | } | |||
| 56 | + | }() | |||
| 57 | + | ||||
| 58 | + | txQueries := r.queries.WithTx(tx) | |||
| 59 | + | if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | |||
| 44 | SessionID: string(state.SessionID), | 60 | SessionID: string(state.SessionID), | ||
| 45 | StateJson: string(stateJSON), | 61 | StateJson: string(stateJSON), | ||
| 46 | Version: int64(state.Version), | 62 | Version: int64(state.Version), | ||
| 47 | TabCount: int64(len(state.Tabs)), | 63 | TabCount: int64(len(state.Tabs)), | ||
| 48 | PaneCount: int64(state.CountPanes()), | 64 | PaneCount: int64(state.CountPanes()), | ||
| 49 | UpdatedAt: state.SavedAt, | 65 | UpdatedAt: state.SavedAt, | ||
| 50 | - | }) | 66 | + | }); err != nil { |
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | if err := tx.Commit(); err != nil { | |||
| 71 | + | return fmt.Errorf("commit snapshot transaction: %w", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | return nil | |||
| 51 | } | 75 | } | ||
| 52 | 76 | ||||
| 53 | // GetSnapshot returns the latest snapshot for a session. | 77 | // GetSnapshot returns the latest snapshot for a session. | ||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +16 -0
| @@ -410,6 +410,22 @@ func (q *Queries) IncrementVisitCount(ctx context.Context, url string) error { | |||||
| 410 | return err | 410 | return err | ||
| 411 | } | 411 | } | ||
| 412 | 412 | ||||
| 413 | + | const IncrementVisitCountByDelta = `-- name: IncrementVisitCountByDelta :exec | |||
| 414 | + | UPDATE history | |||
| 415 | + | SET visit_count = visit_count + ?, last_visited = CURRENT_TIMESTAMP | |||
| 416 | + | WHERE url = ? | |||
| 417 | + | ` | |||
| 418 | + | ||||
| 419 | + | type IncrementVisitCountByDeltaParams struct { | |||
| 420 | + | VisitCount sql.NullInt64 `json:"visit_count"` | |||
| 421 | + | Url string `json:"url"` | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error { | |||
| 425 | + | _, err := q.db.ExecContext(ctx, IncrementVisitCountByDelta, arg.VisitCount, arg.Url) | |||
| 426 | + | return err | |||
| 427 | + | } | |||
| 428 | + | ||||
| 413 | const SearchHistory = `-- name: SearchHistory :many | 429 | const SearchHistory = `-- name: SearchHistory :many | ||
| 414 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | 430 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | ||
| 415 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | 431 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
| @@ -64,6 +64,7 @@ type Querier interface { | |||||
| 64 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | 64 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | ||
| 65 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | 65 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | ||
| 66 | IncrementVisitCount(ctx context.Context, url string) error | 66 | IncrementVisitCount(ctx context.Context, url string) error | ||
| 67 | + | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | |||
| 67 | InsertSession(ctx context.Context, arg InsertSessionParams) error | 68 | InsertSession(ctx context.Context, arg InsertSessionParams) error | ||
| 68 | IsWhitelisted(ctx context.Context, domain string) (int64, error) | 69 | IsWhitelisted(ctx context.Context, domain string) (int64, error) | ||
| 69 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | 70 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | ||
M internal/infrastructure/snapshot/service.go +113 -4
| @@ -2,6 +2,9 @@ package snapshot | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 5 | "sync" | 8 | "sync" | ||
| 6 | "time" | 9 | "time" | ||
| 7 | 10 | ||||
| @@ -10,11 +13,19 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | ) | 14 | ) | ||
| 12 | 15 | ||||
| 16 | + | const ( | |||
| 17 | + | defaultSnapshotIntervalMs = 5000 | |||
| 18 | + | maxFKRetries = 3 | |||
| 19 | + | fkRetryDelay = 100 * time.Millisecond | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 13 | // Service handles debounced session state snapshots. | 22 | // Service handles debounced session state snapshots. | ||
| 14 | type Service struct { | 23 | type Service struct { | ||
| 15 | snapshotUC *usecase.SnapshotSessionUseCase | 24 | snapshotUC *usecase.SnapshotSessionUseCase | ||
| 16 | provider port.TabListProvider | 25 | provider port.TabListProvider | ||
| 17 | interval time.Duration | 26 | interval time.Duration | ||
| 27 | + | retries int | |||
| 28 | + | retryDelay time.Duration | |||
| 18 | 29 | ||||
| 19 | mu sync.Mutex | 30 | mu sync.Mutex | ||
| 20 | timer *time.Timer | 31 | timer *time.Timer | ||
| @@ -31,12 +42,14 @@ func NewService( | |||||
| 31 | intervalMs int, | 42 | intervalMs int, | ||
| 32 | ) *Service { | 43 | ) *Service { | ||
| 33 | if intervalMs <= 0 { | 44 | if intervalMs <= 0 { | ||
| 34 | - | intervalMs = 5000 // Default 5 seconds | 45 | + | intervalMs = defaultSnapshotIntervalMs |
| 35 | } | 46 | } | ||
| 36 | return &Service{ | 47 | return &Service{ | ||
| 37 | snapshotUC: snapshotUC, | 48 | snapshotUC: snapshotUC, | ||
| 38 | provider: provider, | 49 | provider: provider, | ||
| 39 | interval: time.Duration(intervalMs) * time.Millisecond, | 50 | interval: time.Duration(intervalMs) * time.Millisecond, | ||
| 51 | + | retries: maxFKRetries, | |||
| 52 | + | retryDelay: fkRetryDelay, | |||
| 40 | } | 53 | } | ||
| 41 | } | 54 | } | ||
| 42 | 55 | ||||
| @@ -54,8 +67,24 @@ func (s *Service) Start(ctx context.Context) { | |||||
| 54 | // to avoid FK constraint violations. | 67 | // to avoid FK constraint violations. | ||
| 55 | func (s *Service) SetReady() { | 68 | func (s *Service) SetReady() { | ||
| 56 | s.mu.Lock() | 69 | s.mu.Lock() | ||
| 57 | - | defer s.mu.Unlock() | 70 | + | if s.timer != nil { |
| 71 | + | s.timer.Stop() | |||
| 72 | + | s.timer = nil | |||
| 73 | + | } | |||
| 58 | s.ready = true | 74 | s.ready = true | ||
| 75 | + | dirty := s.dirty | |||
| 76 | + | ctx := s.ctx | |||
| 77 | + | s.mu.Unlock() | |||
| 78 | + | ||||
| 79 | + | if !dirty || ctx == nil { | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | go func() { | |||
| 84 | + | if err := s.saveSnapshot(ctx); err != nil { | |||
| 85 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready") | |||
| 86 | + | } | |||
| 87 | + | }() | |||
| 59 | } | 88 | } | ||
| 60 | 89 | ||||
| 61 | // Stop stops the service and saves final state. | 90 | // Stop stops the service and saves final state. | ||
| @@ -135,11 +164,91 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||||
| 135 | sessionID := s.provider.GetSessionID() | 164 | sessionID := s.provider.GetSessionID() | ||
| 136 | 165 | ||||
| 137 | if sessionID == "" { | 166 | if sessionID == "" { | ||
| 167 | + | s.markDirty() | |||
| 138 | return nil | 168 | return nil | ||
| 139 | } | 169 | } | ||
| 140 | 170 | ||||
| 141 | - | return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{ | 171 | + | if err := s.executeWithRetry(ctx, usecase.SnapshotInput{ |
| 142 | SessionID: sessionID, | 172 | SessionID: sessionID, | ||
| 143 | TabList: tabList, | 173 | TabList: tabList, | ||
| 144 | - | }) | 174 | + | }); err != nil { |
| 175 | + | s.markDirty() | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error { | |||
| 183 | + | log := logging.FromContext(ctx) | |||
| 184 | + | maxAttempts := s.retries + 1 | |||
| 185 | + | ||||
| 186 | + | var lastErr error | |||
| 187 | + | for attempt := 1; attempt <= maxAttempts; attempt++ { | |||
| 188 | + | if err := s.snapshotUC.Execute(ctx, input); err != nil { | |||
| 189 | + | lastErr = err | |||
| 190 | + | if !isTransientFKError(err) || attempt == maxAttempts { | |||
| 191 | + | if attempt == maxAttempts && isTransientFKError(err) { | |||
| 192 | + | log.Error(). | |||
| 193 | + | Err(err). | |||
| 194 | + | Str("session_id", string(input.SessionID)). | |||
| 195 | + | Int("attempts", maxAttempts). | |||
| 196 | + | Msg("snapshot save retries exhausted after transient fk violation") | |||
| 197 | + | } | |||
| 198 | + | return err | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | log.Warn(). | |||
| 202 | + | Err(err). | |||
| 203 | + | Str("session_id", string(input.SessionID)). | |||
| 204 | + | Int("attempt", attempt). | |||
| 205 | + | Int("max_attempts", maxAttempts). | |||
| 206 | + | Dur("retry_delay", s.retryDelay). | |||
| 207 | + | Msg("transient fk violation while saving snapshot; retrying") | |||
| 208 | + | ||||
| 209 | + | if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil { | |||
| 210 | + | return fmt.Errorf("waiting to retry snapshot save: %w", waitErr) | |||
| 211 | + | } | |||
| 212 | + | continue | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | return nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | return lastErr | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (s *Service) markDirty() { | |||
| 222 | + | s.mu.Lock() | |||
| 223 | + | s.dirty = true | |||
| 224 | + | s.mu.Unlock() | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func waitForRetry(ctx context.Context, delay time.Duration) error { | |||
| 228 | + | if delay <= 0 { | |||
| 229 | + | return nil | |||
| 230 | + | } | |||
| 231 | + | timer := time.NewTimer(delay) | |||
| 232 | + | defer timer.Stop() | |||
| 233 | + | ||||
| 234 | + | select { | |||
| 235 | + | case <-ctx.Done(): | |||
| 236 | + | return ctx.Err() | |||
| 237 | + | case <-timer.C: | |||
| 238 | + | return nil | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func isTransientFKError(err error) bool { | |||
| 243 | + | // SQLite-specific transient FK detection: | |||
| 244 | + | // we match case-insensitive "foreign key" text from wrapped driver errors. | |||
| 245 | + | // Message wording may vary across SQLite versions/wrappers; adjust this check | |||
| 246 | + | // if transient FK retries stop matching expected failures in production logs. | |||
| 247 | + | if err == nil { | |||
| 248 | + | return false | |||
| 249 | + | } | |||
| 250 | + | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |||
| 251 | + | return false | |||
| 252 | + | } | |||
| 253 | + | return strings.Contains(strings.ToLower(err.Error()), "foreign key") | |||
| 145 | } | 254 | } | ||
A internal/infrastructure/snapshot/service_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type testProvider struct { | |||
| 18 | + | sessionID entity.SessionID | |||
| 19 | + | tabList *entity.TabList | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p *testProvider) GetTabList() *entity.TabList { | |||
| 23 | + | return p.tabList | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (p *testProvider) GetSessionID() entity.SessionID { | |||
| 27 | + | return p.sessionID | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) { | |||
| 31 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 32 | + | calls := 0 | |||
| 33 | + | repo.EXPECT(). | |||
| 34 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 35 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 36 | + | calls++ | |||
| 37 | + | if calls == 1 { | |||
| 38 | + | return errors.New("FOREIGN KEY constraint failed") | |||
| 39 | + | } | |||
| 40 | + | return nil | |||
| 41 | + | }) | |||
| 42 | + | ||||
| 43 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 44 | + | svc := NewService(uc, &testProvider{ | |||
| 45 | + | sessionID: "20260207_120000_fk_retry_ok", | |||
| 46 | + | tabList: entity.NewTabList(), | |||
| 47 | + | }, 1) | |||
| 48 | + | svc.retryDelay = time.Millisecond | |||
| 49 | + | svc.ready = true | |||
| 50 | + | svc.dirty = true | |||
| 51 | + | ||||
| 52 | + | err := svc.saveSnapshot(context.Background()) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | assert.Equal(t, 2, calls) | |||
| 55 | + | assert.False(t, svc.dirty) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) { | |||
| 59 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 60 | + | calls := 0 | |||
| 61 | + | fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed") | |||
| 62 | + | repo.EXPECT(). | |||
| 63 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 64 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 65 | + | calls++ | |||
| 66 | + | return fkErr | |||
| 67 | + | }) | |||
| 68 | + | ||||
| 69 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 70 | + | svc := NewService(uc, &testProvider{ | |||
| 71 | + | sessionID: "20260207_120000_fk_retry_fail", | |||
| 72 | + | tabList: entity.NewTabList(), | |||
| 73 | + | }, 1) | |||
| 74 | + | svc.retryDelay = time.Millisecond | |||
| 75 | + | svc.ready = true | |||
| 76 | + | svc.dirty = true | |||
| 77 | + | ||||
| 78 | + | err := svc.saveSnapshot(context.Background()) | |||
| 79 | + | require.Error(t, err) | |||
| 80 | + | require.ErrorIs(t, err, fkErr) | |||
| 81 | + | assert.Equal(t, svc.retries+1, calls) | |||
| 82 | + | assert.True(t, svc.dirty) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) { | |||
| 86 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 87 | + | calls := 0 | |||
| 88 | + | nonFKErr := errors.New("database is read-only") | |||
| 89 | + | repo.EXPECT(). | |||
| 90 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 91 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 92 | + | calls++ | |||
| 93 | + | return nonFKErr | |||
| 94 | + | }) | |||
| 95 | + | ||||
| 96 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 97 | + | svc := NewService(uc, &testProvider{ | |||
| 98 | + | sessionID: "20260207_120000_non_fk", | |||
| 99 | + | tabList: entity.NewTabList(), | |||
| 100 | + | }, 1) | |||
| 101 | + | svc.retryDelay = time.Millisecond | |||
| 102 | + | svc.ready = true | |||
| 103 | + | svc.dirty = true | |||
| 104 | + | ||||
| 105 | + | err := svc.saveSnapshot(context.Background()) | |||
| 106 | + | require.Error(t, err) | |||
| 107 | + | require.ErrorIs(t, err, nonFKErr) | |||
| 108 | + | assert.Equal(t, 1, calls) | |||
| 109 | + | assert.True(t, svc.dirty) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) { | |||
| 113 | + | repo := repomocks.NewMockSessionStateRepository(t) | |||
| 114 | + | saved := make(chan struct{}, 1) | |||
| 115 | + | repo.EXPECT(). | |||
| 116 | + | SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |||
| 117 | + | RunAndReturn(func(_ context.Context, _ *entity.SessionState) error { | |||
| 118 | + | saved <- struct{}{} | |||
| 119 | + | return nil | |||
| 120 | + | }) | |||
| 121 | + | ||||
| 122 | + | uc := usecase.NewSnapshotSessionUseCase(repo) | |||
| 123 | + | svc := NewService(uc, &testProvider{ | |||
| 124 | + | sessionID: "20260207_120000_ready_flush", | |||
| 125 | + | tabList: entity.NewTabList(), | |||
| 126 | + | }, 1) | |||
| 127 | + | svc.Start(context.Background()) | |||
| 128 | + | svc.dirty = true | |||
| 129 | + | ||||
| 130 | + | svc.SetReady() | |||
| 131 | + | ||||
| 132 | + | select { | |||
| 133 | + | case <-saved: | |||
| 134 | + | case <-time.After(500 * time.Millisecond): | |||
| 135 | + | t.Fatal("expected pending snapshot to be saved after SetReady") | |||
| 136 | + | } | |||
| 137 | + | svc.mu.Lock() | |||
| 138 | + | dirty := svc.dirty | |||
| 139 | + | svc.mu.Unlock() | |||
| 140 | + | assert.False(t, dirty) | |||
| 141 | + | } | |||
M internal/infrastructure/updater/github.go +176 -7
| @@ -12,6 +12,8 @@ import ( | |||||
| 12 | "errors" | 12 | "errors" | ||
| 13 | "fmt" | 13 | "fmt" | ||
| 14 | "io" | 14 | "io" | ||
| 15 | + | "math/rand" | |||
| 16 | + | "net" | |||
| 15 | "net/http" | 17 | "net/http" | ||
| 16 | "net/url" | 18 | "net/url" | ||
| 17 | "os" | 19 | "os" | ||
| @@ -20,8 +22,10 @@ import ( | |||||
| 20 | "runtime" | 22 | "runtime" | ||
| 21 | "strconv" | 23 | "strconv" | ||
| 22 | "strings" | 24 | "strings" | ||
| 25 | + | "syscall" | |||
| 23 | "time" | 26 | "time" | ||
| 24 | 27 | ||||
| 28 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 25 | "github.com/bnema/dumber/internal/domain/entity" | 29 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 26 | "github.com/bnema/dumber/internal/infrastructure/env" | 30 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 27 | "github.com/bnema/dumber/internal/logging" | 31 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -58,6 +62,18 @@ const ( | |||||
| 58 | 62 | ||||
| 59 | // Allowed GitHub download host. | 63 | // Allowed GitHub download host. | ||
| 60 | allowedDownloadHost = "github.com" | 64 | allowedDownloadHost = "github.com" | ||
| 65 | + | ||||
| 66 | + | // Maximum number of attempts for retryable network requests. | |||
| 67 | + | maxRetryAttempts = 3 | |||
| 68 | + | ||||
| 69 | + | // Base delay used for exponential backoff between retries. | |||
| 70 | + | retryBaseDelay = 250 * time.Millisecond | |||
| 71 | + | ||||
| 72 | + | // Maximum delay cap for exponential backoff between retries. | |||
| 73 | + | retryMaxDelay = 2 * time.Second | |||
| 74 | + | ||||
| 75 | + | // Max random jitter added to each retry backoff. | |||
| 76 | + | retryJitterMax = 200 * time.Millisecond | |||
| 61 | ) | 77 | ) | ||
| 62 | 78 | ||||
| 63 | // githubRelease represents the GitHub API response for a release. | 79 | // githubRelease represents the GitHub API response for a release. | ||
| @@ -70,13 +86,17 @@ type githubRelease struct { | |||||
| 70 | 86 | ||||
| 71 | // GitHubChecker implements UpdateChecker using the GitHub API. | 87 | // GitHubChecker implements UpdateChecker using the GitHub API. | ||
| 72 | type GitHubChecker struct { | 88 | type GitHubChecker struct { | ||
| 73 | - | client *http.Client | 89 | + | client *http.Client |
| 90 | + | randInt63 func(n int64) int64 | |||
| 91 | + | sleep func(ctx context.Context, d time.Duration) error | |||
| 74 | } | 92 | } | ||
| 75 | 93 | ||||
| 76 | // NewGitHubChecker creates a new GitHub-based update checker. | 94 | // NewGitHubChecker creates a new GitHub-based update checker. | ||
| 77 | func NewGitHubChecker() *GitHubChecker { | 95 | func NewGitHubChecker() *GitHubChecker { | ||
| 78 | return &GitHubChecker{ | 96 | return &GitHubChecker{ | ||
| 79 | - | client: &http.Client{Timeout: apiTimeout}, | 97 | + | client: &http.Client{Timeout: apiTimeout}, |
| 98 | + | randInt63: rand.Int63n, | |||
| 99 | + | sleep: waitForBackoff, | |||
| 80 | } | 100 | } | ||
| 81 | } | 101 | } | ||
| 82 | 102 | ||||
| @@ -115,13 +135,21 @@ func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion strin | |||||
| 115 | req.Header.Set("Accept", "application/vnd.github.v3+json") | 135 | req.Header.Set("Accept", "application/vnd.github.v3+json") | ||
| 116 | req.Header.Set("User-Agent", "dumber-browser/"+currentVersion) | 136 | req.Header.Set("User-Agent", "dumber-browser/"+currentVersion) | ||
| 117 | 137 | ||||
| 118 | - | resp, err := g.client.Do(req) | 138 | + | resp, err := g.doRequestWithRetry(ctx, req) |
| 119 | if err != nil { | 139 | if err != nil { | ||
| 140 | + | if isRetryableRequestError(err) { | |||
| 141 | + | log.Debug().Err(err).Msg("transient update check failure; skipping check") | |||
| 142 | + | return nil, fmt.Errorf("%w: %w", port.ErrUpdateCheckTransient, err) | |||
| 143 | + | } | |||
| 120 | return nil, fmt.Errorf("failed to fetch latest release: %w", err) | 144 | return nil, fmt.Errorf("failed to fetch latest release: %w", err) | ||
| 121 | } | 145 | } | ||
| 122 | defer func() { _ = resp.Body.Close() }() | 146 | defer func() { _ = resp.Body.Close() }() | ||
| 123 | 147 | ||||
| 124 | if resp.StatusCode != http.StatusOK { | 148 | if resp.StatusCode != http.StatusOK { | ||
| 149 | + | if isRetryableStatus(resp.StatusCode) { | |||
| 150 | + | log.Debug().Int("status", resp.StatusCode).Msg("transient update check API status; skipping check") | |||
| 151 | + | return nil, fmt.Errorf("%w: github API returned status %d", port.ErrUpdateCheckTransient, resp.StatusCode) | |||
| 152 | + | } | |||
| 125 | return nil, fmt.Errorf("github API returned status %d", resp.StatusCode) | 153 | return nil, fmt.Errorf("github API returned status %d", resp.StatusCode) | ||
| 126 | } | 154 | } | ||
| 127 | 155 | ||||
| @@ -214,16 +242,153 @@ func compareVersions(v1, v2 string) int { | |||||
| 214 | 242 | ||||
| 215 | // GitHubDownloader implements UpdateDownloader for GitHub releases. | 243 | // GitHubDownloader implements UpdateDownloader for GitHub releases. | ||
| 216 | type GitHubDownloader struct { | 244 | type GitHubDownloader struct { | ||
| 217 | - | client *http.Client | 245 | + | client *http.Client |
| 246 | + | randInt63 func(n int64) int64 | |||
| 247 | + | sleep func(ctx context.Context, d time.Duration) error | |||
| 218 | } | 248 | } | ||
| 219 | 249 | ||||
| 220 | // NewGitHubDownloader creates a new GitHub release downloader. | 250 | // NewGitHubDownloader creates a new GitHub release downloader. | ||
| 221 | func NewGitHubDownloader() *GitHubDownloader { | 251 | func NewGitHubDownloader() *GitHubDownloader { | ||
| 222 | return &GitHubDownloader{ | 252 | return &GitHubDownloader{ | ||
| 223 | - | client: &http.Client{Timeout: downloadTimeout}, | 253 | + | client: &http.Client{Timeout: downloadTimeout}, |
| 254 | + | randInt63: rand.Int63n, | |||
| 255 | + | sleep: waitForBackoff, | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func isRetryableStatus(status int) bool { | |||
| 260 | + | if status == http.StatusForbidden || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout { | |||
| 261 | + | return true | |||
| 262 | + | } | |||
| 263 | + | return status >= http.StatusInternalServerError | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func isRetryableRequestError(err error) bool { | |||
| 267 | + | if err == nil { | |||
| 268 | + | return false | |||
| 269 | + | } | |||
| 270 | + | if errors.Is(err, context.Canceled) { | |||
| 271 | + | return false | |||
| 272 | + | } | |||
| 273 | + | if errors.Is(err, context.DeadlineExceeded) { | |||
| 274 | + | return true | |||
| 275 | + | } | |||
| 276 | + | ||||
| 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 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | var opErr *net.OpError | |||
| 289 | + | if errors.As(err, &opErr) { | |||
| 290 | + | if isTransientSyscallError(opErr.Err) { | |||
| 291 | + | return true | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | return false | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func isTransientSyscallError(err error) bool { | |||
| 299 | + | var errno syscall.Errno | |||
| 300 | + | if !errors.As(err, &errno) { | |||
| 301 | + | return false | |||
| 302 | + | } | |||
| 303 | + | switch errno { | |||
| 304 | + | case syscall.ECONNRESET, syscall.ECONNREFUSED, | |||
| 305 | + | syscall.EADDRNOTAVAIL, syscall.ENETUNREACH, | |||
| 306 | + | syscall.EHOSTUNREACH: | |||
| 307 | + | return true | |||
| 308 | + | } | |||
| 309 | + | return false | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | func waitForBackoff(ctx context.Context, d time.Duration) error { | |||
| 313 | + | timer := time.NewTimer(d) | |||
| 314 | + | defer timer.Stop() | |||
| 315 | + | ||||
| 316 | + | select { | |||
| 317 | + | case <-ctx.Done(): | |||
| 318 | + | return ctx.Err() | |||
| 319 | + | case <-timer.C: | |||
| 320 | + | return nil | |||
| 224 | } | 321 | } | ||
| 225 | } | 322 | } | ||
| 226 | 323 | ||||
| 324 | + | func retryDelayForAttempt(attempt int, randInt63 func(n int64) int64) time.Duration { | |||
| 325 | + | if attempt < 1 { | |||
| 326 | + | attempt = 1 | |||
| 327 | + | } | |||
| 328 | + | delay := retryBaseDelay | |||
| 329 | + | for i := 1; i < attempt; i++ { | |||
| 330 | + | if delay >= retryMaxDelay { | |||
| 331 | + | delay = retryMaxDelay | |||
| 332 | + | break | |||
| 333 | + | } | |||
| 334 | + | delay *= 2 | |||
| 335 | + | if delay > retryMaxDelay { | |||
| 336 | + | delay = retryMaxDelay | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | if randInt63 != nil && retryJitterMax > 0 { | |||
| 341 | + | delay += time.Duration(randInt63(int64(retryJitterMax))) | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | if delay > retryMaxDelay { | |||
| 345 | + | delay = retryMaxDelay | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | return delay | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // doRequestWithRetryHelper retries the same request instance across attempts. | |||
| 352 | + | // It assumes req.Body is non-consumable (for example http.NoBody); it does not clone | |||
| 353 | + | // the request or rewind req.Body. Callers needing retries with a real body must provide | |||
| 354 | + | // a fresh cloned request per attempt or use a rewindable body implementation. | |||
| 355 | + | func doRequestWithRetryHelper( | |||
| 356 | + | ctx context.Context, | |||
| 357 | + | client *http.Client, | |||
| 358 | + | req *http.Request, | |||
| 359 | + | sleep func(ctx context.Context, d time.Duration) error, | |||
| 360 | + | randInt63 func(n int64) int64, | |||
| 361 | + | ) (*http.Response, error) { | |||
| 362 | + | for attempt := 1; ; attempt++ { | |||
| 363 | + | if attempt > maxRetryAttempts { | |||
| 364 | + | return nil, fmt.Errorf("request failed after retries") | |||
| 365 | + | } | |||
| 366 | + | resp, err := client.Do(req) | |||
| 367 | + | if err != nil { | |||
| 368 | + | if !isRetryableRequestError(err) || attempt == maxRetryAttempts { | |||
| 369 | + | return nil, err | |||
| 370 | + | } | |||
| 371 | + | if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil { | |||
| 372 | + | return nil, waitErr | |||
| 373 | + | } | |||
| 374 | + | continue | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | if !isRetryableStatus(resp.StatusCode) || attempt == maxRetryAttempts { | |||
| 378 | + | return resp, nil | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | _ = resp.Body.Close() | |||
| 382 | + | if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil { | |||
| 383 | + | return nil, waitErr | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) { | |||
| 389 | + | return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 227 | // validateDownloadURL ensures the URL is a valid GitHub releases URL. | 392 | // validateDownloadURL ensures the URL is a valid GitHub releases URL. | ||
| 228 | func validateDownloadURL(downloadURL string) error { | 393 | func validateDownloadURL(downloadURL string) error { | ||
| 229 | parsed, err := url.Parse(downloadURL) | 394 | parsed, err := url.Parse(downloadURL) | ||
| @@ -257,7 +422,7 @@ func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]strin | |||||
| 257 | } | 422 | } | ||
| 258 | req.Header.Set("User-Agent", "dumber-browser") | 423 | req.Header.Set("User-Agent", "dumber-browser") | ||
| 259 | 424 | ||||
| 260 | - | resp, err := g.client.Do(req) | 425 | + | resp, err := g.doRequestWithRetry(ctx, req) |
| 261 | if err != nil { | 426 | if err != nil { | ||
| 262 | return nil, fmt.Errorf("failed to fetch checksums: %w", err) | 427 | return nil, fmt.Errorf("failed to fetch checksums: %w", err) | ||
| 263 | } | 428 | } | ||
| @@ -362,7 +527,7 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st | |||||
| 362 | 527 | ||||
| 363 | log.Debug().Str("url", downloadURL).Msg("downloading update") | 528 | log.Debug().Str("url", downloadURL).Msg("downloading update") | ||
| 364 | 529 | ||||
| 365 | - | resp, err := g.client.Do(req) | 530 | + | resp, err := g.doRequestWithRetry(ctx, req) |
| 366 | if err != nil { | 531 | if err != nil { | ||
| 367 | return "", fmt.Errorf("failed to download: %w", err) | 532 | return "", fmt.Errorf("failed to download: %w", err) | ||
| 368 | } | 533 | } | ||
| @@ -423,6 +588,10 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st | |||||
| 423 | return archivePath, nil | 588 | return archivePath, nil | ||
| 424 | } | 589 | } | ||
| 425 | 590 | ||||
| 591 | + | func (g *GitHubDownloader) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) { | |||
| 592 | + | return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63) | |||
| 593 | + | } | |||
| 594 | + | ||||
| 426 | // sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal. | 595 | // sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal. | ||
| 427 | func sanitizeTarPath(name, destDir string) (string, error) { | 596 | func sanitizeTarPath(name, destDir string) (string, error) { | ||
| 428 | // Clean the path to remove any . or .. components. | 597 | // Clean the path to remove any . or .. components. | ||
M internal/infrastructure/updater/github_test.go +141 -0
| @@ -1,12 +1,20 @@ | |||||
| 1 | package updater | 1 | package updater | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "crypto/sha256" | 5 | "crypto/sha256" | ||
| 5 | "encoding/hex" | 6 | "encoding/hex" | ||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "net/http" | |||
| 6 | "os" | 10 | "os" | ||
| 7 | "path/filepath" | 11 | "path/filepath" | ||
| 8 | "strings" | 12 | "strings" | ||
| 13 | + | "sync/atomic" | |||
| 9 | "testing" | 14 | "testing" | ||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | ) | 18 | ) | ||
| 11 | 19 | ||||
| 12 | func TestCompareVersions(t *testing.T) { | 20 | func TestCompareVersions(t *testing.T) { | ||
| @@ -355,7 +363,140 @@ func TestSizeLimits(t *testing.T) { | |||||
| 355 | } | 363 | } | ||
| 356 | } | 364 | } | ||
| 357 | 365 | ||||
| 366 | + | func TestRetryDelayForAttempt(t *testing.T) { | |||
| 367 | + | tests := []struct { | |||
| 368 | + | name string | |||
| 369 | + | attempt int | |||
| 370 | + | want time.Duration | |||
| 371 | + | }{ | |||
| 372 | + | {name: "first attempt", attempt: 1, want: retryBaseDelay}, | |||
| 373 | + | {name: "second attempt", attempt: 2, want: retryBaseDelay * 2}, | |||
| 374 | + | {name: "third attempt", attempt: 3, want: retryBaseDelay * 4}, | |||
| 375 | + | {name: "capped attempt", attempt: 6, want: retryMaxDelay}, | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | for _, tt := range tests { | |||
| 379 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 380 | + | got := retryDelayForAttempt(tt.attempt, func(_ int64) int64 { return 0 }) | |||
| 381 | + | if got != tt.want { | |||
| 382 | + | t.Fatalf("retryDelayForAttempt(%d) = %v, want %v", tt.attempt, got, tt.want) | |||
| 383 | + | } | |||
| 384 | + | }) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestRetryDelayForAttemptAddsJitter(t *testing.T) { | |||
| 389 | + | got := retryDelayForAttempt(1, func(_ int64) int64 { return int64(50 * time.Millisecond) }) | |||
| 390 | + | want := retryBaseDelay + (50 * time.Millisecond) | |||
| 391 | + | if got != want { | |||
| 392 | + | t.Fatalf("retryDelayForAttempt with jitter = %v, want %v", got, want) | |||
| 393 | + | } | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) { | |||
| 397 | + | var calls int32 | |||
| 398 | + | tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) { | |||
| 399 | + | attempt := atomic.AddInt32(&calls, 1) | |||
| 400 | + | if attempt < maxRetryAttempts { | |||
| 401 | + | return testResponse(http.StatusForbidden, "forbidden"), nil | |||
| 402 | + | } | |||
| 403 | + | return testResponse(http.StatusOK, `{"tag_name":"v1.2.3"}`), nil | |||
| 404 | + | }) | |||
| 405 | + | ||||
| 406 | + | checker := NewGitHubChecker() | |||
| 407 | + | checker.client = &http.Client{Transport: tr} | |||
| 408 | + | checker.randInt63 = func(_ int64) int64 { return 0 } | |||
| 409 | + | checker.sleep = func(context.Context, time.Duration) error { return nil } | |||
| 410 | + | ||||
| 411 | + | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody) | |||
| 412 | + | if err != nil { | |||
| 413 | + | t.Fatalf("failed to create request: %v", err) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | resp, err := checker.doRequestWithRetry(context.Background(), req) | |||
| 417 | + | if err != nil { | |||
| 418 | + | t.Fatalf("doRequestWithRetry() unexpected error: %v", err) | |||
| 419 | + | } | |||
| 420 | + | defer func() { _ = resp.Body.Close() }() | |||
| 421 | + | ||||
| 422 | + | if resp.StatusCode != http.StatusOK { | |||
| 423 | + | t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) | |||
| 424 | + | } | |||
| 425 | + | if atomic.LoadInt32(&calls) != maxRetryAttempts { | |||
| 426 | + | t.Fatalf("calls = %d, want %d", calls, maxRetryAttempts) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func TestGitHubCheckerCheckForUpdate_TransientError(t *testing.T) { | |||
| 431 | + | var calls int32 | |||
| 432 | + | tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) { | |||
| 433 | + | atomic.AddInt32(&calls, 1) | |||
| 434 | + | return testResponse(http.StatusForbidden, "forbidden"), nil | |||
| 435 | + | }) | |||
| 436 | + | ||||
| 437 | + | checker := NewGitHubChecker() | |||
| 438 | + | checker.client = &http.Client{Transport: tr} | |||
| 439 | + | checker.randInt63 = func(_ int64) int64 { return 0 } | |||
| 440 | + | checker.sleep = func(context.Context, time.Duration) error { return nil } | |||
| 441 | + | ||||
| 442 | + | _, err := checker.CheckForUpdate(context.Background(), "1.0.0") | |||
| 443 | + | if err == nil { | |||
| 444 | + | t.Fatal("expected error, got nil") | |||
| 445 | + | } | |||
| 446 | + | if !errors.Is(err, port.ErrUpdateCheckTransient) { | |||
| 447 | + | t.Fatalf("expected transient error, got %v", err) | |||
| 448 | + | } | |||
| 449 | + | if atomic.LoadInt32(&calls) != maxRetryAttempts { | |||
| 450 | + | t.Fatalf("calls = %d, want %d", calls, maxRetryAttempts) | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func TestGitHubDownloaderDoRequestWithRetry_NoRetryOnClientError(t *testing.T) { | |||
| 455 | + | var calls int32 | |||
| 456 | + | tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) { | |||
| 457 | + | atomic.AddInt32(&calls, 1) | |||
| 458 | + | return testResponse(http.StatusNotFound, "not found"), nil | |||
| 459 | + | }) | |||
| 460 | + | ||||
| 461 | + | downloader := NewGitHubDownloader() | |||
| 462 | + | downloader.client = &http.Client{Transport: tr} | |||
| 463 | + | downloader.randInt63 = func(_ int64) int64 { return 0 } | |||
| 464 | + | downloader.sleep = func(context.Context, time.Duration) error { return nil } | |||
| 465 | + | ||||
| 466 | + | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, checksumsURLTemplate, http.NoBody) | |||
| 467 | + | if err != nil { | |||
| 468 | + | t.Fatalf("failed to create request: %v", err) | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | resp, err := downloader.doRequestWithRetry(context.Background(), req) | |||
| 472 | + | if err != nil { | |||
| 473 | + | t.Fatalf("doRequestWithRetry() unexpected error: %v", err) | |||
| 474 | + | } | |||
| 475 | + | defer func() { _ = resp.Body.Close() }() | |||
| 476 | + | ||||
| 477 | + | if resp.StatusCode != http.StatusNotFound { | |||
| 478 | + | t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound) | |||
| 479 | + | } | |||
| 480 | + | if atomic.LoadInt32(&calls) != 1 { | |||
| 481 | + | t.Fatalf("calls = %d, want 1", calls) | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | ||||
| 358 | // contains checks if substr is in s (helper for error message checks). | 485 | // contains checks if substr is in s (helper for error message checks). | ||
| 359 | func contains(s, substr string) bool { | 486 | func contains(s, substr string) bool { | ||
| 360 | return strings.Contains(s, substr) | 487 | return strings.Contains(s, substr) | ||
| 361 | } | 488 | } | ||
| 489 | + | ||||
| 490 | + | type roundTripFunc func(*http.Request) (*http.Response, error) | |||
| 491 | + | ||||
| 492 | + | func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 493 | + | return f(req) | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | func testResponse(status int, body string) *http.Response { | |||
| 497 | + | return &http.Response{ | |||
| 498 | + | StatusCode: status, | |||
| 499 | + | Body: io.NopCloser(strings.NewReader(body)), | |||
| 500 | + | Header: make(http.Header), | |||
| 501 | + | } | |||
| 502 | + | } | |||
M internal/infrastructure/webkit/context.go +25 -6
| @@ -37,8 +37,10 @@ type WebKitContext struct { | |||||
| 37 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | 37 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | ||
| 38 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | 38 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | ||
| 39 | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | 39 | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | ||
| 40 | - | DataDir: dataDir, | 40 | + | DataDir: dataDir, |
| 41 | - | CacheDir: cacheDir, | 41 | + | CacheDir: cacheDir, |
| 42 | + | CookiePolicy: port.WebKitCookiePolicyNoThirdParty, | |||
| 43 | + | ITPEnabled: true, | |||
| 42 | }) | 44 | }) | ||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| @@ -71,7 +73,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt | |||||
| 71 | 73 | ||||
| 72 | // Create persistent network session FIRST | 74 | // Create persistent network session FIRST | ||
| 73 | // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default" | 75 | // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default" | ||
| 74 | - | if err := wkCtx.initNetworkSession(); err != nil { | 76 | + | if err := wkCtx.initNetworkSession(opts); err != nil { |
| 75 | return nil, fmt.Errorf("failed to init network session: %w", err) | 77 | return nil, fmt.Errorf("failed to init network session: %w", err) | ||
| 76 | } | 78 | } | ||
| 77 | 79 | ||||
| @@ -112,7 +114,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt | |||||
| 112 | } | 114 | } | ||
| 113 | 115 | ||||
| 114 | // initNetworkSession creates and configures the persistent network session. | 116 | // initNetworkSession creates and configures the persistent network session. | ||
| 115 | - | func (c *WebKitContext) initNetworkSession() error { | 117 | + | func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error { |
| 116 | // Create persistent network session | 118 | // Create persistent network session | ||
| 117 | session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir) | 119 | session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir) | ||
| 118 | if session == nil { | 120 | if session == nil { | ||
| @@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error { | |||||
| 158 | 160 | ||||
| 159 | cookiePath := filepath.Join(c.dataDir, "cookies.db") | 161 | cookiePath := filepath.Join(c.dataDir, "cookies.db") | ||
| 160 | cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue) | 162 | cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue) | ||
| 161 | - | cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdPartyValue) | 163 | + | cookiePolicy, cookiePolicyLabel := mapCookiePolicy(opts.CookiePolicy) |
| 164 | + | cookieManager.SetAcceptPolicy(cookiePolicy) | |||
| 165 | + | session.SetItpEnabled(opts.ITPEnabled) | |||
| 162 | 166 | ||||
| 163 | - | c.logger.Debug(). | 167 | + | c.logger.Info(). |
| 164 | Str("cookie_path", cookiePath). | 168 | Str("cookie_path", cookiePath). | ||
| 169 | + | Str("cookie_policy", cookiePolicyLabel). | |||
| 170 | + | Bool("itp_enabled", opts.ITPEnabled). | |||
| 165 | Msg("cookie storage configured") | 171 | Msg("cookie storage configured") | ||
| 166 | 172 | ||||
| 167 | // Enable persistent credential storage | 173 | // Enable persistent credential storage | ||
| @@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error { | |||||
| 182 | return nil | 188 | return nil | ||
| 183 | } | 189 | } | ||
| 184 | 190 | ||||
| 191 | + | func mapCookiePolicy(policy port.WebKitCookiePolicy) (webkit.CookieAcceptPolicy, string) { | |||
| 192 | + | switch policy { | |||
| 193 | + | case port.WebKitCookiePolicyAlways: | |||
| 194 | + | return webkit.CookiePolicyAcceptAlwaysValue, string(port.WebKitCookiePolicyAlways) | |||
| 195 | + | case port.WebKitCookiePolicyNever: | |||
| 196 | + | return webkit.CookiePolicyAcceptNeverValue, string(port.WebKitCookiePolicyNever) | |||
| 197 | + | case port.WebKitCookiePolicyNoThirdParty, "": | |||
| 198 | + | return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty) | |||
| 199 | + | default: | |||
| 200 | + | return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 185 | // Context returns the shared WebContext. | 204 | // Context returns the shared WebContext. | ||
| 186 | func (c *WebKitContext) Context() *webkit.WebContext { | 205 | func (c *WebKitContext) Context() *webkit.WebContext { | ||
| 187 | c.mu.RLock() | 206 | c.mu.RLock() | ||
M internal/infrastructure/webkit/messaging.go +4 -4
| @@ -47,7 +47,7 @@ type MessageRouter struct { | |||||
| 47 | 47 | ||||
| 48 | mu sync.RWMutex | 48 | mu sync.RWMutex | ||
| 49 | callbacks []interface{} | 49 | callbacks []interface{} | ||
| 50 | - | signals []uint32 | 50 | + | signals []uint |
| 51 | 51 | ||||
| 52 | idMu sync.Mutex | 52 | idMu sync.Mutex | ||
| 53 | syncedIDs map[WebViewID]bool | 53 | syncedIDs map[WebViewID]bool | ||
| @@ -121,7 +121,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal | |||||
| 121 | // It registers the script message handler in the MAIN world (not isolated). | 121 | // It registers the script message handler in the MAIN world (not isolated). | ||
| 122 | // WebKit's messageHandlers is only available in main world. | 122 | // WebKit's messageHandlers is only available in main world. | ||
| 123 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | 123 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | ||
| 124 | - | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint32, error) { | 124 | + | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) { |
| 125 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | 125 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | ||
| 126 | 126 | ||||
| 127 | log.Debug().Msg("SetupMessageHandler called") | 127 | log.Debug().Msg("SetupMessageHandler called") | ||
| @@ -146,7 +146,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||||
| 146 | 146 | ||||
| 147 | // Connect to "script-message-received::dumber" with handler name as signal detail | 147 | // Connect to "script-message-received::dumber" with handler name as signal detail | ||
| 148 | signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb) | 148 | signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb) | ||
| 149 | - | log.Debug().Uint32("signal_id", signalID).Str("handler", MessageHandlerName).Msg("signal connected with detail") | 149 | + | log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail") |
| 150 | 150 | ||||
| 151 | r.mu.Lock() | 151 | r.mu.Lock() | ||
| 152 | r.signals = append(r.signals, signalID) | 152 | r.signals = append(r.signals, signalID) | ||
| @@ -164,7 +164,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||||
| 164 | log.Info(). | 164 | log.Info(). | ||
| 165 | Str("handler", MessageHandlerName). | 165 | Str("handler", MessageHandlerName). | ||
| 166 | Str("world", "main"). | 166 | Str("world", "main"). | ||
| 167 | - | Uint32("signal_id", signalID). | 167 | + | Uint64("signal_id", uint64(signalID)). |
| 168 | Msg("script message handler connected") | 168 | Msg("script message handler connected") | ||
| 169 | 169 | ||||
| 170 | return signalID, nil | 170 | return signalID, nil | ||
M internal/infrastructure/webkit/pool.go +182 -39
| @@ -3,6 +3,8 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "os" | |||
| 7 | + | "strings" | |||
| 6 | "sync" | 8 | "sync" | ||
| 7 | "sync/atomic" | 9 | "sync/atomic" | ||
| 8 | "time" | 10 | "time" | ||
| @@ -31,6 +33,12 @@ type PoolConfig struct { | |||||
| 31 | PrewarmCount int | 33 | PrewarmCount int | ||
| 32 | // IdleTimeout is how long idle views stay in the pool before being destroyed. | 34 | // IdleTimeout is how long idle views stay in the pool before being destroyed. | ||
| 33 | IdleTimeout time.Duration | 35 | IdleTimeout time.Duration | ||
| 36 | + | // ReusePolicy controls if/when released WebViews are returned to pool. | |||
| 37 | + | // Values: off, safe, aggressive. | |||
| 38 | + | ReusePolicy string | |||
| 39 | + | // TerminatePolicy controls whether Destroy() should terminate web process. | |||
| 40 | + | // Values: auto, always, never. | |||
| 41 | + | TerminatePolicy string | |||
| 34 | } | 42 | } | ||
| 35 | 43 | ||||
| 36 | // DefaultPoolConfig returns sensible defaults for the pool. | 44 | // DefaultPoolConfig returns sensible defaults for the pool. | ||
| @@ -43,6 +51,12 @@ func DefaultPoolConfig() PoolConfig { | |||||
| 43 | } | 51 | } | ||
| 44 | } | 52 | } | ||
| 45 | 53 | ||||
| 54 | + | const ( | |||
| 55 | + | reusePolicyOff = "off" | |||
| 56 | + | reusePolicySafe = "safe" | |||
| 57 | + | reusePolicyAggressive = "aggressive" | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 46 | // WebViewPool manages a pool of pre-created WebViews for fast tab creation. | 60 | // WebViewPool manages a pool of pre-created WebViews for fast tab creation. | ||
| 47 | type WebViewPool struct { | 61 | type WebViewPool struct { | ||
| 48 | wkCtx *WebKitContext | 62 | wkCtx *WebKitContext | ||
| @@ -50,6 +64,7 @@ type WebViewPool struct { | |||||
| 50 | config PoolConfig | 64 | config PoolConfig | ||
| 51 | 65 | ||||
| 52 | pool chan *WebView | 66 | pool chan *WebView | ||
| 67 | + | poolMu sync.Mutex | |||
| 53 | injector *ContentInjector | 68 | injector *ContentInjector | ||
| 54 | router *MessageRouter | 69 | router *MessageRouter | ||
| 55 | filterApplier FilterApplier // Optional content filter applier | 70 | filterApplier FilterApplier // Optional content filter applier | ||
| @@ -59,6 +74,19 @@ type WebViewPool struct { | |||||
| 59 | 74 | ||||
| 60 | closed atomic.Bool | 75 | closed atomic.Bool | ||
| 61 | wg sync.WaitGroup | 76 | wg sync.WaitGroup | ||
| 77 | + | ||||
| 78 | + | reusePolicy string | |||
| 79 | + | terminatePolicy string | |||
| 80 | + | ||||
| 81 | + | acquireCount atomic.Uint64 | |||
| 82 | + | acquirePoolHits atomic.Uint64 | |||
| 83 | + | acquirePoolMisses atomic.Uint64 | |||
| 84 | + | releaseCount atomic.Uint64 | |||
| 85 | + | releaseReused atomic.Uint64 | |||
| 86 | + | releaseDestroyed atomic.Uint64 | |||
| 87 | + | releaseDropped atomic.Uint64 | |||
| 88 | + | releaseTotalNanos atomic.Int64 | |||
| 89 | + | acquireTotalNanos atomic.Int64 | |||
| 62 | } | 90 | } | ||
| 63 | 91 | ||||
| 64 | // NewWebViewPool creates a new WebView pool. | 92 | // NewWebViewPool creates a new WebView pool. | ||
| @@ -79,21 +107,28 @@ func NewWebViewPool( | |||||
| 79 | if cfg.MinSize > cfg.MaxSize { | 107 | if cfg.MinSize > cfg.MaxSize { | ||
| 80 | cfg.MinSize = cfg.MaxSize | 108 | cfg.MinSize = cfg.MaxSize | ||
| 81 | } | 109 | } | ||
| 110 | + | cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy) | |||
| 111 | + | cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy) | |||
| 82 | if ctx == nil { | 112 | if ctx == nil { | ||
| 83 | ctx = context.Background() | 113 | ctx = context.Background() | ||
| 84 | } | 114 | } | ||
| 85 | 115 | ||||
| 86 | p := &WebViewPool{ | 116 | p := &WebViewPool{ | ||
| 87 | - | wkCtx: wkCtx, | 117 | + | wkCtx: wkCtx, |
| 88 | - | settings: settings, | 118 | + | settings: settings, |
| 89 | - | config: cfg, | 119 | + | config: cfg, |
| 90 | - | pool: make(chan *WebView, cfg.MaxSize), | 120 | + | pool: make(chan *WebView, cfg.MaxSize), |
| 91 | - | injector: injector, | 121 | + | injector: injector, |
| 92 | - | router: router, | 122 | + | router: router, |
| 123 | + | reusePolicy: cfg.ReusePolicy, | |||
| 124 | + | terminatePolicy: cfg.TerminatePolicy, | |||
| 93 | } | 125 | } | ||
| 94 | 126 | ||||
| 95 | log := logging.FromContext(ctx) | 127 | log := logging.FromContext(ctx) | ||
| 96 | - | log.Debug().Msg("creating webview pool") | 128 | + | log.Debug(). |
| 129 | + | Str("reuse_policy", p.reusePolicy). | |||
| 130 | + | Str("terminate_policy", p.terminatePolicy). | |||
| 131 | + | Msg("creating webview pool") | |||
| 97 | 132 | ||||
| 98 | return p | 133 | return p | ||
| 99 | } | 134 | } | ||
| @@ -113,6 +148,7 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||||
| 113 | // Acquire gets a WebView from the pool or creates a new one. | 148 | // Acquire gets a WebView from the pool or creates a new one. | ||
| 114 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | 149 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | ||
| 115 | log := logging.FromContext(ctx) | 150 | log := logging.FromContext(ctx) | ||
| 151 | + | start := time.Now() | |||
| 116 | 152 | ||||
| 117 | if p.closed.Load() { | 153 | if p.closed.Load() { | ||
| 118 | return nil, ErrPoolClosed | 154 | return nil, ErrPoolClosed | ||
| @@ -135,7 +171,15 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 135 | if p.filterApplier != nil { | 171 | if p.filterApplier != nil { | ||
| 136 | p.filterApplier.ApplyTo(ctx, wv.ucm) | 172 | p.filterApplier.ApplyTo(ctx, wv.ucm) | ||
| 137 | } | 173 | } | ||
| 138 | - | log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool") | 174 | + | p.acquireCount.Add(1) |
| 175 | + | p.acquirePoolHits.Add(1) | |||
| 176 | + | p.acquireTotalNanos.Add(time.Since(start).Nanoseconds()) | |||
| 177 | + | log.Debug(). | |||
| 178 | + | Uint64("id", uint64(wv.ID())). | |||
| 179 | + | Dur("latency", time.Since(start)). | |||
| 180 | + | Int("pool_size", len(p.pool)). | |||
| 181 | + | Str("source", "pool"). | |||
| 182 | + | Msg("acquired webview") | |||
| 139 | return wv, nil | 183 | return wv, nil | ||
| 140 | } | 184 | } | ||
| 141 | // Fall through to create new if the pooled one was destroyed | 185 | // Fall through to create new if the pooled one was destroyed | ||
| @@ -145,7 +189,28 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||||
| 145 | 189 | ||||
| 146 | // Pool empty or pooled view was destroyed, create new | 190 | // Pool empty or pooled view was destroyed, create new | ||
| 147 | log.Debug().Msg("pool empty, creating new webview") | 191 | log.Debug().Msg("pool empty, creating new webview") | ||
| 148 | - | return p.createWebView(ctx) | 192 | + | wv, err := p.createWebView(ctx) |
| 193 | + | if err != nil { | |||
| 194 | + | return nil, err | |||
| 195 | + | } | |||
| 196 | + | p.acquireCount.Add(1) | |||
| 197 | + | p.acquirePoolMisses.Add(1) | |||
| 198 | + | p.acquireTotalNanos.Add(time.Since(start).Nanoseconds()) | |||
| 199 | + | ||||
| 200 | + | // Re-check after creation: if pool closed during createWebView, destroy | |||
| 201 | + | // the new WebView and return an error to avoid leaking resources. | |||
| 202 | + | if p.closed.Load() { | |||
| 203 | + | wv.DestroyWithPolicy(p.terminatePolicy) | |||
| 204 | + | return nil, ErrPoolClosed | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | log.Debug(). | |||
| 208 | + | Uint64("id", uint64(wv.ID())). | |||
| 209 | + | Dur("latency", time.Since(start)). | |||
| 210 | + | Int("pool_size", len(p.pool)). | |||
| 211 | + | Str("source", "new"). | |||
| 212 | + | Msg("acquired webview") | |||
| 213 | + | return wv, nil | |||
| 149 | } | 214 | } | ||
| 150 | 215 | ||||
| 151 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | 216 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | ||
| @@ -174,29 +239,52 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||||
| 174 | return wv, nil | 239 | return wv, nil | ||
| 175 | } | 240 | } | ||
| 176 | 241 | ||||
| 177 | - | // Release returns a WebView to the pool. | 242 | + | // Release returns a WebView to the pool when configured and safe. |
| 178 | - | // For safety, we always destroy and never pool WebViews that have been used. | 243 | + | // Default behavior remains conservative (`reuse_policy=off`) and destroys on release. |
| 179 | - | // This prevents crashes from GLib signals firing after the widget tree is modified. | |||
| 180 | - | // The pool is only used for prewarmed WebViews that haven't been attached to UI yet. | |||
| 181 | func (p *WebViewPool) Release(ctx context.Context, wv *WebView) { | 244 | func (p *WebViewPool) Release(ctx context.Context, wv *WebView) { | ||
| 182 | log := logging.FromContext(ctx) | 245 | log := logging.FromContext(ctx) | ||
| 246 | + | start := time.Now() | |||
| 183 | 247 | ||||
| 184 | if wv == nil || wv.IsDestroyed() || p.closed.Load() { | 248 | if wv == nil || wv.IsDestroyed() || p.closed.Load() { | ||
| 185 | if wv != nil && !wv.IsDestroyed() { | 249 | if wv != nil && !wv.IsDestroyed() { | ||
| 186 | - | wv.Destroy() | 250 | + | wv.DestroyWithPolicy(p.terminatePolicy) |
| 187 | } | 251 | } | ||
| 252 | + | p.releaseDropped.Add(1) | |||
| 188 | return | 253 | return | ||
| 189 | } | 254 | } | ||
| 190 | 255 | ||||
| 191 | - | // CRITICAL: Always destroy used WebViews instead of pooling them. | 256 | + | action := "destroyed" |
| 192 | - | // GLib signals are connected with closures that can fire asynchronously | 257 | + | if p.shouldReuseOnRelease(wv) { |
| 193 | - | // even after we clear Go callbacks. Loading about:blank triggers signals | 258 | + | wv.ResetForPoolReuse() |
| 194 | - | // that can crash if the widget tree has been modified. | 259 | + | if p.tryPut(ctx, wv) { |
| 195 | - | // | 260 | + | action = "reused" |
| 196 | - | // The pool is designed for prewarmed WebViews only, not for recycling | 261 | + | p.releaseReused.Add(1) |
| 197 | - | // WebViews that have been attached to the UI. | 262 | + | } else { |
| 198 | - | log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)") | 263 | + | action = "destroyed_after_reuse_failed" |
| 199 | - | wv.Destroy() | 264 | + | wv.DestroyWithPolicy(p.terminatePolicy) |
| 265 | + | p.releaseDestroyed.Add(1) | |||
| 266 | + | } | |||
| 267 | + | } else { | |||
| 268 | + | wv.DestroyWithPolicy(p.terminatePolicy) | |||
| 269 | + | p.releaseDestroyed.Add(1) | |||
| 270 | + | } | |||
| 271 | + | p.releaseCount.Add(1) | |||
| 272 | + | elapsed := time.Since(start) | |||
| 273 | + | p.releaseTotalNanos.Add(elapsed.Nanoseconds()) | |||
| 274 | + | log.Debug(). | |||
| 275 | + | Uint64("id", uint64(wv.ID())). | |||
| 276 | + | Str("action", action). | |||
| 277 | + | Str("reuse_policy", p.reusePolicy). | |||
| 278 | + | Str("terminate_policy", p.terminatePolicy). | |||
| 279 | + | Dur("latency", elapsed). | |||
| 280 | + | Int("pool_size", len(p.pool)). | |||
| 281 | + | Uint64("acquire_total", p.acquireCount.Load()). | |||
| 282 | + | Uint64("acquire_hits", p.acquirePoolHits.Load()). | |||
| 283 | + | Uint64("acquire_misses", p.acquirePoolMisses.Load()). | |||
| 284 | + | Uint64("release_total", p.releaseCount.Load()). | |||
| 285 | + | Uint64("release_reused", p.releaseReused.Load()). | |||
| 286 | + | Uint64("release_destroyed", p.releaseDestroyed.Load()). | |||
| 287 | + | Msg("released webview") | |||
| 200 | } | 288 | } | ||
| 201 | 289 | ||||
| 202 | // PrewarmFirst creates exactly one WebView synchronously. | 290 | // PrewarmFirst creates exactly one WebView synchronously. | ||
| @@ -218,12 +306,11 @@ func (p *WebViewPool) PrewarmFirst(ctx context.Context) error { | |||||
| 218 | return err | 306 | return err | ||
| 219 | } | 307 | } | ||
| 220 | 308 | ||||
| 221 | - | select { | 309 | + | if p.tryPut(ctx, wv) { |
| 222 | - | case p.pool <- wv: | |||
| 223 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start") | 310 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start") | ||
| 224 | - | default: | 311 | + | return nil |
| 225 | - | wv.Destroy() // Pool somehow full (shouldn't happen) | |||
| 226 | } | 312 | } | ||
| 313 | + | wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen) | |||
| 227 | return nil | 314 | return nil | ||
| 228 | } | 315 | } | ||
| 229 | 316 | ||||
| @@ -294,11 +381,10 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { | |||||
| 294 | if err != nil { | 381 | if err != nil { | ||
| 295 | log.Warn().Err(err).Msg("failed to prewarm webview") | 382 | log.Warn().Err(err).Msg("failed to prewarm webview") | ||
| 296 | } else { | 383 | } else { | ||
| 297 | - | select { | 384 | + | if p.tryPut(ctx, wv) { |
| 298 | - | case p.pool <- wv: | |||
| 299 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | 385 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | ||
| 300 | - | default: | 386 | + | } else { |
| 301 | - | wv.Destroy() | 387 | + | wv.DestroyWithPolicy(p.terminatePolicy) |
| 302 | } | 388 | } | ||
| 303 | } | 389 | } | ||
| 304 | 390 | ||||
| @@ -336,12 +422,11 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | |||||
| 336 | } | 422 | } | ||
| 337 | 423 | ||||
| 338 | // Try to add to pool | 424 | // Try to add to pool | ||
| 339 | - | select { | 425 | + | if p.tryPut(ctx, wv) { |
| 340 | - | case p.pool <- wv: | |||
| 341 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | 426 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | ||
| 342 | - | default: | 427 | + | } else { |
| 343 | // Pool full, destroy | 428 | // Pool full, destroy | ||
| 344 | - | wv.Destroy() | 429 | + | wv.DestroyWithPolicy(p.terminatePolicy) |
| 345 | } | 430 | } | ||
| 346 | 431 | ||||
| 347 | // Small delay between creations to avoid overwhelming the system | 432 | // Small delay between creations to avoid overwhelming the system | ||
| @@ -387,11 +472,10 @@ refreshLoop: | |||||
| 387 | } | 472 | } | ||
| 388 | 473 | ||||
| 389 | // Put back in pool | 474 | // Put back in pool | ||
| 390 | - | select { | 475 | + | if p.tryPut(ctx, wv) { |
| 391 | - | case p.pool <- wv: | |||
| 392 | refreshed++ | 476 | refreshed++ | ||
| 393 | - | default: | 477 | + | } else { |
| 394 | - | wv.Destroy() | 478 | + | wv.DestroyWithPolicy(p.terminatePolicy) |
| 395 | } | 479 | } | ||
| 396 | default: | 480 | default: | ||
| 397 | // Pool was modified during iteration, stop | 481 | // Pool was modified during iteration, stop | ||
| @@ -417,7 +501,9 @@ func (p *WebViewPool) Close(ctx context.Context) { | |||||
| 417 | p.wg.Wait() | 501 | p.wg.Wait() | ||
| 418 | 502 | ||||
| 419 | // Drain and destroy all pooled views | 503 | // Drain and destroy all pooled views | ||
| 504 | + | p.poolMu.Lock() | |||
| 420 | close(p.pool) | 505 | close(p.pool) | ||
| 506 | + | p.poolMu.Unlock() | |||
| 421 | for wv := range p.pool { | 507 | for wv := range p.pool { | ||
| 422 | if wv != nil && !wv.IsDestroyed() { | 508 | if wv != nil && !wv.IsDestroyed() { | ||
| 423 | wv.Destroy() | 509 | wv.Destroy() | ||
| @@ -426,3 +512,60 @@ func (p *WebViewPool) Close(ctx context.Context) { | |||||
| 426 | 512 | ||||
| 427 | log.Debug().Msg("webview pool closed") | 513 | log.Debug().Msg("webview pool closed") | ||
| 428 | } | 514 | } | ||
| 515 | + | ||||
| 516 | + | func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool { | |||
| 517 | + | if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() { | |||
| 518 | + | return false | |||
| 519 | + | } | |||
| 520 | + | if len(p.pool) >= p.config.MaxSize { | |||
| 521 | + | return false | |||
| 522 | + | } | |||
| 523 | + | switch p.reusePolicy { | |||
| 524 | + | case reusePolicySafe: | |||
| 525 | + | // Safe mode: only reuse views that haven't navigated, avoiding | |||
| 526 | + | // back/forward/page context leaking across tabs. | |||
| 527 | + | return !wv.HasNavigationActivity() | |||
| 528 | + | case reusePolicyAggressive: | |||
| 529 | + | // Aggressive mode: reuse regardless of navigation activity. | |||
| 530 | + | // The view is reset via ResetForPoolReuse before being returned to the pool. | |||
| 531 | + | return true | |||
| 532 | + | default: | |||
| 533 | + | return false | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool { | |||
| 538 | + | log := logging.FromContext(ctx) | |||
| 539 | + | p.poolMu.Lock() | |||
| 540 | + | defer p.poolMu.Unlock() | |||
| 541 | + | if p.closed.Load() { | |||
| 542 | + | return false | |||
| 543 | + | } | |||
| 544 | + | select { | |||
| 545 | + | case p.pool <- wv: | |||
| 546 | + | log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool") | |||
| 547 | + | return true | |||
| 548 | + | default: | |||
| 549 | + | return false | |||
| 550 | + | } | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | func resolveReusePolicy(raw string) string { | |||
| 554 | + | switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) { | |||
| 555 | + | case reusePolicySafe: | |||
| 556 | + | return reusePolicySafe | |||
| 557 | + | case reusePolicyAggressive: | |||
| 558 | + | return reusePolicyAggressive | |||
| 559 | + | case reusePolicyOff, "": | |||
| 560 | + | return reusePolicyOff | |||
| 561 | + | default: | |||
| 562 | + | return reusePolicyOff | |||
| 563 | + | } | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | func defaultIfEmpty(value, fallback string) string { | |||
| 567 | + | if strings.TrimSpace(value) != "" { | |||
| 568 | + | return value | |||
| 569 | + | } | |||
| 570 | + | return fallback | |||
| 571 | + | } | |||
A internal/infrastructure/webkit/pool_policy_test.go +18 -0
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestResolveReusePolicy(t *testing.T) { | |||
| 10 | + | t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "") | |||
| 11 | + | assert.Equal(t, reusePolicyOff, resolveReusePolicy("")) | |||
| 12 | + | assert.Equal(t, reusePolicySafe, resolveReusePolicy("safe")) | |||
| 13 | + | assert.Equal(t, reusePolicyAggressive, resolveReusePolicy("aggressive")) | |||
| 14 | + | assert.Equal(t, reusePolicyOff, resolveReusePolicy("unknown")) | |||
| 15 | + | ||||
| 16 | + | t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe") | |||
| 17 | + | assert.Equal(t, reusePolicySafe, resolveReusePolicy("")) | |||
| 18 | + | } | |||
M internal/infrastructure/webkit/scheme_handler.go +157 -2
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "embed" | 5 | "embed" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "html" | |||
| 8 | "io/fs" | 9 | "io/fs" | ||
| 9 | "mime" | 10 | "mime" | ||
| 10 | "net/http" | 11 | "net/http" | ||
| @@ -28,7 +29,9 @@ const ( | |||||
| 28 | HomePath = "home" | 29 | HomePath = "home" | ||
| 29 | ConfigPath = "config" | 30 | ConfigPath = "config" | ||
| 30 | ErrorPath = "error" | 31 | ErrorPath = "error" | ||
| 32 | + | CrashPath = "crash" | |||
| 31 | IndexHTML = "index.html" | 33 | IndexHTML = "index.html" | ||
| 34 | + | httpGET = "GET" | |||
| 32 | ) | 35 | ) | ||
| 33 | 36 | ||||
| 34 | // SchemeRequest represents a request to a custom URI scheme. | 37 | // SchemeRequest represents a request to a custom URI scheme. | ||
| @@ -161,9 +164,22 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 161 | } | 164 | } | ||
| 162 | })) | 165 | })) | ||
| 163 | 166 | ||||
| 167 | + | // Crash page (web process termination fallback) | |||
| 168 | + | h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 169 | + | if req.Method != "" && req.Method != httpGET { | |||
| 170 | + | return nil | |||
| 171 | + | } | |||
| 172 | + | originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI)) | |||
| 173 | + | return &SchemeResponse{ | |||
| 174 | + | Data: []byte(buildCrashPageHTML(originalURI)), | |||
| 175 | + | ContentType: "text/html; charset=utf-8", | |||
| 176 | + | StatusCode: http.StatusOK, | |||
| 177 | + | } | |||
| 178 | + | })) | |||
| 179 | + | ||||
| 164 | // API: Get current config (used by dumb://config) | 180 | // API: Get current config (used by dumb://config) | ||
| 165 | h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | 181 | h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | ||
| 166 | - | if req.Method != "GET" { | 182 | + | if req.Method != "" && req.Method != httpGET { |
| 167 | return nil | 183 | return nil | ||
| 168 | } | 184 | } | ||
| 169 | 185 | ||||
| @@ -172,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 172 | 188 | ||||
| 173 | // API: Get default config (used by Reset Defaults in dumb://config) | 189 | // API: Get default config (used by Reset Defaults in dumb://config) | ||
| 174 | h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | 190 | h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | ||
| 175 | - | if req.Method != "GET" { | 191 | + | if req.Method != "" && req.Method != httpGET { |
| 176 | return nil | 192 | return nil | ||
| 177 | } | 193 | } | ||
| 178 | 194 | ||||
| @@ -180,6 +196,145 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 180 | })) | 196 | })) | ||
| 181 | } | 197 | } | ||
| 182 | 198 | ||||
| 199 | + | func crashOriginalURI(requestURI string) string { | |||
| 200 | + | if requestURI == "" { | |||
| 201 | + | return "" | |||
| 202 | + | } | |||
| 203 | + | parsed, err := url.Parse(requestURI) | |||
| 204 | + | if err != nil { | |||
| 205 | + | return "" | |||
| 206 | + | } | |||
| 207 | + | return strings.TrimSpace(parsed.Query().Get("url")) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func sanitizeCrashPageOriginalURI(originalURI string) string { | |||
| 211 | + | originalURI = strings.TrimSpace(originalURI) | |||
| 212 | + | if originalURI == "" { | |||
| 213 | + | return "" | |||
| 214 | + | } | |||
| 215 | + | parsed, err := url.Parse(originalURI) | |||
| 216 | + | if err != nil { | |||
| 217 | + | return "" | |||
| 218 | + | } | |||
| 219 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 220 | + | case "http", "https": | |||
| 221 | + | if parsed.Host == "" { | |||
| 222 | + | return "" | |||
| 223 | + | } | |||
| 224 | + | return parsed.String() | |||
| 225 | + | case "dumb": | |||
| 226 | + | if parsed.Host == "" && parsed.Opaque == "" { | |||
| 227 | + | return "" | |||
| 228 | + | } | |||
| 229 | + | return parsed.String() | |||
| 230 | + | default: | |||
| 231 | + | return "" | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func buildCrashPageHTML(originalURI string) string { | |||
| 236 | + | escapedURI := html.EscapeString(originalURI) | |||
| 237 | + | return fmt.Sprintf(`<!DOCTYPE html> | |||
| 238 | + | <html lang="en"> | |||
| 239 | + | <head> | |||
| 240 | + | <meta charset="utf-8"> | |||
| 241 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 242 | + | <title>Renderer crashed</title> | |||
| 243 | + | <style> | |||
| 244 | + | :root { | |||
| 245 | + | color-scheme: dark; | |||
| 246 | + | font-family: "IBM Plex Sans", "Segoe UI", sans-serif; | |||
| 247 | + | } | |||
| 248 | + | body { | |||
| 249 | + | margin: 0; | |||
| 250 | + | min-height: 100vh; | |||
| 251 | + | display: flex; | |||
| 252 | + | align-items: center; | |||
| 253 | + | justify-content: center; | |||
| 254 | + | background: radial-gradient(circle at top, #253447, #101622 55%%); | |||
| 255 | + | color: #f2f6fa; | |||
| 256 | + | padding: 24px; | |||
| 257 | + | } | |||
| 258 | + | .card { | |||
| 259 | + | width: min(640px, 100%%); | |||
| 260 | + | background: rgba(10, 16, 26, 0.86); | |||
| 261 | + | border: 1px solid rgba(144, 173, 205, 0.35); | |||
| 262 | + | border-radius: 16px; | |||
| 263 | + | box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45); | |||
| 264 | + | padding: 28px; | |||
| 265 | + | } | |||
| 266 | + | h1 { | |||
| 267 | + | margin: 0 0 12px; | |||
| 268 | + | font-size: 1.8rem; | |||
| 269 | + | } | |||
| 270 | + | p { | |||
| 271 | + | margin: 0 0 16px; | |||
| 272 | + | line-height: 1.5; | |||
| 273 | + | color: #c8d6e6; | |||
| 274 | + | } | |||
| 275 | + | .url { | |||
| 276 | + | margin: 0 0 20px; | |||
| 277 | + | padding: 12px; | |||
| 278 | + | border-radius: 10px; | |||
| 279 | + | background: rgba(26, 38, 56, 0.85); | |||
| 280 | + | border: 1px solid rgba(139, 167, 194, 0.28); | |||
| 281 | + | font-family: "IBM Plex Mono", "Fira Code", monospace; | |||
| 282 | + | font-size: 0.92rem; | |||
| 283 | + | overflow-wrap: anywhere; | |||
| 284 | + | } | |||
| 285 | + | .actions { | |||
| 286 | + | display: flex; | |||
| 287 | + | gap: 12px; | |||
| 288 | + | flex-wrap: wrap; | |||
| 289 | + | } | |||
| 290 | + | button { | |||
| 291 | + | border: 0; | |||
| 292 | + | border-radius: 10px; | |||
| 293 | + | padding: 10px 16px; | |||
| 294 | + | cursor: pointer; | |||
| 295 | + | font-size: 0.95rem; | |||
| 296 | + | font-weight: 600; | |||
| 297 | + | } | |||
| 298 | + | .primary { | |||
| 299 | + | background: #4dd0e1; | |||
| 300 | + | color: #061018; | |||
| 301 | + | } | |||
| 302 | + | .secondary { | |||
| 303 | + | background: #233346; | |||
| 304 | + | color: #d6e5f5; | |||
| 305 | + | } | |||
| 306 | + | </style> | |||
| 307 | + | </head> | |||
| 308 | + | <body> | |||
| 309 | + | <div class="card"> | |||
| 310 | + | <h1>Renderer process ended</h1> | |||
| 311 | + | <p>The current page was interrupted. You can reload it to continue browsing.</p> | |||
| 312 | + | <div class="url">%s</div> | |||
| 313 | + | <div class="actions"> | |||
| 314 | + | <button class="primary" id="reload-btn" data-target="%s">Reload page</button> | |||
| 315 | + | <button class="secondary" id="stay-btn">Stay on this page</button> | |||
| 316 | + | </div> | |||
| 317 | + | </div> | |||
| 318 | + | <script> | |||
| 319 | + | const reloadButton = document.getElementById('reload-btn'); | |||
| 320 | + | const targetUrl = (reloadButton.getAttribute('data-target') || '').trim(); | |||
| 321 | + | reloadButton.addEventListener('click', function() { | |||
| 322 | + | if (targetUrl) { | |||
| 323 | + | window.location.href = targetUrl; | |||
| 324 | + | return; | |||
| 325 | + | } | |||
| 326 | + | window.location.reload(); | |||
| 327 | + | }); | |||
| 328 | + | // "Stay on this page" keeps the crash page visible without navigating away. | |||
| 329 | + | document.getElementById('stay-btn').addEventListener('click', function() { | |||
| 330 | + | this.disabled = true; | |||
| 331 | + | this.textContent = 'Staying on page'; | |||
| 332 | + | }); | |||
| 333 | + | </script> | |||
| 334 | + | </body> | |||
| 335 | + | </html>`, escapedURI, escapedURI) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 183 | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | 338 | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | ||
| 184 | // Get hardware info for display and profile resolution | 339 | // Get hardware info for display and profile resolution | ||
| 185 | // Use background context since survey results are cached and we don't want | 340 | // Use background context since survey results are cached and we don't want | ||
A internal/infrastructure/webkit/scheme_handler_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCrashOriginalURI(t *testing.T) { | |||
| 12 | + | assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1")) | |||
| 13 | + | assert.Empty(t, crashOriginalURI("dumb://home/crash")) | |||
| 14 | + | assert.Empty(t, crashOriginalURI("%%%")) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestBuildCrashPageHTMLIncludesReloadTarget(t *testing.T) { | |||
| 18 | + | body := buildCrashPageHTML("https://example.com/foo?a=1&b=2") | |||
| 19 | + | assert.Contains(t, body, `data-target="https://example.com/foo?a=1&b=2"`) | |||
| 20 | + | assert.Contains(t, body, "https://example.com/foo?a=1&b=2") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestBuildCrashPageHTMLFallbackReloadWithoutTarget(t *testing.T) { | |||
| 24 | + | body := buildCrashPageHTML("") | |||
| 25 | + | assert.Contains(t, body, "window.location.reload();") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) { | |||
| 29 | + | // buildCrashPageHTML receives already-sanitized input from the caller. | |||
| 30 | + | // sanitizeCrashPageOriginalURI strips unsafe schemes to "". | |||
| 31 | + | sanitized := sanitizeCrashPageOriginalURI("javascript:alert(1)") | |||
| 32 | + | assert.Empty(t, sanitized) | |||
| 33 | + | body := buildCrashPageHTML(sanitized) | |||
| 34 | + | assert.Contains(t, body, `data-target=""`) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) { | |||
| 38 | + | body := buildCrashPageHTML("dumb://home") | |||
| 39 | + | assert.Contains(t, body, `data-target="dumb://home"`) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | |||
| 43 | + | payload := `https://example.com/?q=</script><script>alert(1)</script>` | |||
| 44 | + | body := buildCrashPageHTML(payload) | |||
| 45 | + | assert.NotContains(t, body, "</script><script>alert(1)</script>") | |||
| 46 | + | assert.Contains(t, body, "</script><script>alert(1)</script>") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | |||
| 50 | + | handler := NewDumbSchemeHandler(context.Background()) | |||
| 51 | + | require.NotNil(t, handler) | |||
| 52 | + | ||||
| 53 | + | handler.mu.RLock() | |||
| 54 | + | crashHandler, ok := handler.handlers["/crash"] | |||
| 55 | + | handler.mu.RUnlock() | |||
| 56 | + | require.True(t, ok) | |||
| 57 | + | require.NotNil(t, crashHandler) | |||
| 58 | + | ||||
| 59 | + | resp := crashHandler.Handle(&SchemeRequest{ | |||
| 60 | + | URI: "dumb://home/crash?url=https%3A%2F%2Fexample.com", | |||
| 61 | + | Path: "/crash", | |||
| 62 | + | Method: "GET", | |||
| 63 | + | Scheme: "dumb", | |||
| 64 | + | }) | |||
| 65 | + | require.NotNil(t, resp) | |||
| 66 | + | assert.Equal(t, 200, resp.StatusCode) | |||
| 67 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 68 | + | assert.Contains(t, string(resp.Data), "Renderer process ended") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||
| 72 | + | handler := NewDumbSchemeHandler(context.Background()) | |||
| 73 | + | require.NotNil(t, handler) | |||
| 74 | + | ||||
| 75 | + | handler.mu.RLock() | |||
| 76 | + | crashHandler, ok := handler.handlers["/crash"] | |||
| 77 | + | handler.mu.RUnlock() | |||
| 78 | + | require.True(t, ok) | |||
| 79 | + | require.NotNil(t, crashHandler) | |||
| 80 | + | ||||
| 81 | + | resp := crashHandler.Handle(&SchemeRequest{ | |||
| 82 | + | URI: "dumb://home/crash?url=javascript%3Aalert(1)", | |||
| 83 | + | Path: "/crash", | |||
| 84 | + | Method: "GET", | |||
| 85 | + | Scheme: "dumb", | |||
| 86 | + | }) | |||
| 87 | + | require.NotNil(t, resp) | |||
| 88 | + | assert.Contains(t, string(resp.Data), `data-target=""`) | |||
| 89 | + | } | |||
M internal/infrastructure/webkit/webview.go +370 -61
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net/url" | 6 | "net/url" | ||
| 7 | + | "os" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| 9 | "sync/atomic" | 10 | "sync/atomic" | ||
| @@ -135,39 +136,59 @@ type WebView struct { | |||||
| 135 | lastProgressUpdate atomic.Int64 // Unix nanoseconds | 136 | lastProgressUpdate atomic.Int64 // Unix nanoseconds | ||
| 136 | 137 | ||||
| 137 | // Signal handler IDs for disconnection | 138 | // Signal handler IDs for disconnection | ||
| 138 | - | signalIDs []uint32 | 139 | + | signalIDs []uintptr |
| 139 | 140 | ||||
| 140 | // Callbacks (set by UI layer) | 141 | // Callbacks (set by UI layer) | ||
| 141 | - | OnLoadChanged func(LoadEvent) | 142 | + | OnLoadChanged func(LoadEvent) |
| 142 | - | OnTitleChanged func(string) | 143 | + | OnTitleChanged func(string) |
| 143 | - | OnURIChanged func(string) | 144 | + | OnURIChanged func(string) |
| 144 | - | OnProgressChanged func(float64) | 145 | + | OnProgressChanged func(float64) |
| 145 | - | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes | 146 | + | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes |
| 146 | - | OnClose func() | 147 | + | OnClose func() |
| 147 | - | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | 148 | + | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup |
| 148 | - | OnReadyToShow func() // Called when popup is ready to display | 149 | + | OnReadyToShow func() // Called when popup is ready to display |
| 149 | - | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | 150 | + | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) |
| 150 | - | OnEnterFullscreen func() bool // Return true to prevent fullscreen | 151 | + | OnEnterFullscreen func() bool // Return true to prevent fullscreen |
| 151 | - | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | 152 | + | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen |
| 152 | - | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | 153 | + | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops |
| 153 | - | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) | 154 | + | OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving) |
| 155 | + | OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) | |||
| 154 | 156 | ||||
| 155 | logger zerolog.Logger | 157 | logger zerolog.Logger | ||
| 156 | mu sync.RWMutex | 158 | mu sync.RWMutex | ||
| 157 | 159 | ||||
| 158 | frontendAttached atomic.Bool | 160 | frontendAttached atomic.Bool | ||
| 161 | + | navigationActive atomic.Bool | |||
| 159 | 162 | ||||
| 160 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | 163 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | ||
| 161 | asyncCallbacks []interface{} | 164 | asyncCallbacks []interface{} | ||
| 162 | 165 | ||||
| 166 | + | // runJSErrorStats aggregates repeated non-fatal RunJavaScript errors by domain+signature. | |||
| 167 | + | runJSErrorStats map[string]runJSErrorStat | |||
| 168 | + | ||||
| 163 | // findController is cached to prevent GC from collecting the Go wrapper | 169 | // findController is cached to prevent GC from collecting the Go wrapper | ||
| 164 | findController *findControllerAdapter | 170 | findController *findControllerAdapter | ||
| 165 | findControllerOnce sync.Once | 171 | findControllerOnce sync.Once | ||
| 166 | 172 | ||||
| 167 | backForwardList *webkit.BackForwardList | 173 | backForwardList *webkit.BackForwardList | ||
| 168 | - | backForwardListSignalID uint32 | 174 | + | backForwardListSignalID uintptr |
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | type runJSErrorStat struct { | |||
| 178 | + | count uint64 | |||
| 179 | + | lastLog time.Time | |||
| 169 | } | 180 | } | ||
| 170 | 181 | ||||
| 182 | + | const ( | |||
| 183 | + | terminatePolicyAuto = "auto" | |||
| 184 | + | terminatePolicyAlways = "always" | |||
| 185 | + | terminatePolicyNever = "never" | |||
| 186 | + | ||||
| 187 | + | runJSNonFatalLogInterval = 30 * time.Second | |||
| 188 | + | runJSAggregateLogEvery = 20 | |||
| 189 | + | runJSUnknown = "unknown" | |||
| 190 | + | ) | |||
| 191 | + | ||||
| 171 | type findControllerAdapter struct { | 192 | type findControllerAdapter struct { | ||
| 172 | fc *webkit.FindController | 193 | fc *webkit.FindController | ||
| 173 | } | 194 | } | ||
| @@ -234,7 +255,7 @@ func (a *findControllerAdapter) GetSearchText() string { | |||||
| 234 | return a.fc.GetSearchText() | 255 | return a.fc.GetSearchText() | ||
| 235 | } | 256 | } | ||
| 236 | 257 | ||||
| 237 | - | func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 { | 258 | + | func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint { |
| 238 | if a == nil || a.fc == nil || callback == nil { | 259 | if a == nil || a.fc == nil || callback == nil { | ||
| 239 | return 0 | 260 | return 0 | ||
| 240 | } | 261 | } | ||
| @@ -244,7 +265,7 @@ func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint | |||||
| 244 | return a.fc.ConnectFoundText(&cb) | 265 | return a.fc.ConnectFoundText(&cb) | ||
| 245 | } | 266 | } | ||
| 246 | 267 | ||||
| 247 | - | func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 { | 268 | + | func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint { |
| 248 | if a == nil || a.fc == nil || callback == nil { | 269 | if a == nil || a.fc == nil || callback == nil { | ||
| 249 | return 0 | 270 | return 0 | ||
| 250 | } | 271 | } | ||
| @@ -254,7 +275,7 @@ func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 { | |||||
| 254 | return a.fc.ConnectFailedToFindText(&cb) | 275 | return a.fc.ConnectFailedToFindText(&cb) | ||
| 255 | } | 276 | } | ||
| 256 | 277 | ||||
| 257 | - | func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 { | 278 | + | func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint { |
| 258 | if a == nil || a.fc == nil || callback == nil { | 279 | if a == nil || a.fc == nil || callback == nil { | ||
| 259 | return 0 | 280 | return 0 | ||
| 260 | } | 281 | } | ||
| @@ -264,7 +285,7 @@ func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) | |||||
| 264 | return a.fc.ConnectCountedMatches(&cb) | 285 | return a.fc.ConnectCountedMatches(&cb) | ||
| 265 | } | 286 | } | ||
| 266 | 287 | ||||
| 267 | - | func (a *findControllerAdapter) DisconnectSignal(id uint32) { | 288 | + | func (a *findControllerAdapter) DisconnectSignal(id uint) { |
| 268 | if a == nil || a.fc == nil || id == 0 { | 289 | if a == nil || a.fc == nil || id == 0 { | ||
| 269 | return | 290 | return | ||
| 270 | } | 291 | } | ||
| @@ -300,10 +321,11 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||||
| 300 | } | 321 | } | ||
| 301 | 322 | ||||
| 302 | wv := &WebView{ | 323 | wv := &WebView{ | ||
| 303 | - | inner: inner, | 324 | + | inner: inner, |
| 304 | - | ucm: inner.GetUserContentManager(), | 325 | + | ucm: inner.GetUserContentManager(), |
| 305 | - | logger: log.With().Str("component", "webview").Logger(), | 326 | + | logger: log.With().Str("component", "webview").Logger(), |
| 306 | - | signalIDs: make([]uint32, 0, 4), | 327 | + | signalIDs: make([]uintptr, 0, 4), |
| 328 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |||
| 307 | } | 329 | } | ||
| 308 | 330 | ||||
| 309 | // Register in global registry | 331 | // Register in global registry | ||
| @@ -349,11 +371,12 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti | |||||
| 349 | Msg("related webview created, checking pointers") | 371 | Msg("related webview created, checking pointers") | ||
| 350 | 372 | ||||
| 351 | wv := &WebView{ | 373 | wv := &WebView{ | ||
| 352 | - | inner: inner, | 374 | + | inner: inner, |
| 353 | - | isRelated: true, // Shares web process with parent - must not terminate process on destroy | 375 | + | isRelated: true, // Shares web process with parent - must not terminate process on destroy |
| 354 | - | ucm: inner.GetUserContentManager(), | 376 | + | ucm: inner.GetUserContentManager(), |
| 355 | - | logger: log.With().Str("component", "webview-popup").Logger(), | 377 | + | logger: log.With().Str("component", "webview-popup").Logger(), |
| 356 | - | signalIDs: make([]uint32, 0, 6), | 378 | + | signalIDs: make([]uintptr, 0, 6), |
| 379 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |||
| 357 | } | 380 | } | ||
| 358 | 381 | ||||
| 359 | wv.id = globalRegistry.register(wv) | 382 | wv.id = globalRegistry.register(wv) | ||
| @@ -388,6 +411,7 @@ func (wv *WebView) connectSignals() { | |||||
| 388 | wv.connectAudioStateSignal() | 411 | wv.connectAudioStateSignal() | ||
| 389 | wv.connectMouseTargetChangedSignal() | 412 | wv.connectMouseTargetChangedSignal() | ||
| 390 | wv.connectBackForwardListChangedSignal() | 413 | wv.connectBackForwardListChangedSignal() | ||
| 414 | + | wv.connectWebProcessTerminatedSignal() | |||
| 391 | } | 415 | } | ||
| 392 | 416 | ||||
| 393 | func (wv *WebView) connectLoadChangedSignal() { | 417 | func (wv *WebView) connectLoadChangedSignal() { | ||
| @@ -404,6 +428,7 @@ func (wv *WebView) connectLoadChangedSignal() { | |||||
| 404 | 428 | ||||
| 405 | switch event { | 429 | switch event { | ||
| 406 | case webkit.LoadStartedValue: | 430 | case webkit.LoadStartedValue: | ||
| 431 | + | wv.navigationActive.Store(true) | |||
| 407 | wv.isLoading = true | 432 | wv.isLoading = true | ||
| 408 | wv.logger.Debug().Str("uri", uri).Msg("load started") | 433 | wv.logger.Debug().Str("uri", uri).Msg("load started") | ||
| 409 | case webkit.LoadRedirectedValue: | 434 | case webkit.LoadRedirectedValue: | ||
| @@ -421,7 +446,7 @@ func (wv *WebView) connectLoadChangedSignal() { | |||||
| 421 | } | 446 | } | ||
| 422 | } | 447 | } | ||
| 423 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | 448 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | ||
| 424 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 449 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 425 | } | 450 | } | ||
| 426 | 451 | ||||
| 427 | func (wv *WebView) connectCloseSignal() { | 452 | func (wv *WebView) connectCloseSignal() { | ||
| @@ -431,7 +456,7 @@ func (wv *WebView) connectCloseSignal() { | |||||
| 431 | } | 456 | } | ||
| 432 | } | 457 | } | ||
| 433 | sigID := wv.inner.ConnectClose(&closeCb) | 458 | sigID := wv.inner.ConnectClose(&closeCb) | ||
| 434 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 459 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 435 | } | 460 | } | ||
| 436 | 461 | ||||
| 437 | func (wv *WebView) connectCreateSignal() { | 462 | func (wv *WebView) connectCreateSignal() { | ||
| @@ -473,7 +498,7 @@ func (wv *WebView) connectCreateSignal() { | |||||
| 473 | return newWV.inner.Widget | 498 | return newWV.inner.Widget | ||
| 474 | } | 499 | } | ||
| 475 | sigID := wv.inner.ConnectCreate(&createCb) | 500 | sigID := wv.inner.ConnectCreate(&createCb) | ||
| 476 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 501 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 477 | } | 502 | } | ||
| 478 | 503 | ||||
| 479 | func (wv *WebView) connectReadyToShowSignal() { | 504 | func (wv *WebView) connectReadyToShowSignal() { | ||
| @@ -483,7 +508,7 @@ func (wv *WebView) connectReadyToShowSignal() { | |||||
| 483 | } | 508 | } | ||
| 484 | } | 509 | } | ||
| 485 | sigID := wv.inner.ConnectReadyToShow(&readyToShowCb) | 510 | sigID := wv.inner.ConnectReadyToShow(&readyToShowCb) | ||
| 486 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 511 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 487 | } | 512 | } | ||
| 488 | 513 | ||||
| 489 | func (wv *WebView) connectTitleSignal() { | 514 | func (wv *WebView) connectTitleSignal() { | ||
| @@ -498,7 +523,7 @@ func (wv *WebView) connectTitleSignal() { | |||||
| 498 | } | 523 | } | ||
| 499 | } | 524 | } | ||
| 500 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb)) | 525 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb)) | ||
| 501 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 526 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 502 | } | 527 | } | ||
| 503 | 528 | ||||
| 504 | func (wv *WebView) connectURISignal() { | 529 | func (wv *WebView) connectURISignal() { | ||
| @@ -514,7 +539,7 @@ func (wv *WebView) connectURISignal() { | |||||
| 514 | } | 539 | } | ||
| 515 | } | 540 | } | ||
| 516 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb)) | 541 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb)) | ||
| 517 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 542 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 518 | } | 543 | } | ||
| 519 | 544 | ||||
| 520 | func (wv *WebView) connectFaviconSignal() { | 545 | func (wv *WebView) connectFaviconSignal() { | ||
| @@ -525,7 +550,7 @@ func (wv *WebView) connectFaviconSignal() { | |||||
| 525 | } | 550 | } | ||
| 526 | } | 551 | } | ||
| 527 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb)) | 552 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb)) | ||
| 528 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 553 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 529 | } | 554 | } | ||
| 530 | 555 | ||||
| 531 | // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead. | 556 | // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead. | ||
| @@ -555,7 +580,7 @@ func (wv *WebView) connectProgressSignal() { | |||||
| 555 | } | 580 | } | ||
| 556 | } | 581 | } | ||
| 557 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb)) | 582 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb)) | ||
| 558 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 583 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 559 | } | 584 | } | ||
| 560 | 585 | ||||
| 561 | func (wv *WebView) connectDecidePolicySignal() { | 586 | func (wv *WebView) connectDecidePolicySignal() { | ||
| @@ -571,7 +596,7 @@ func (wv *WebView) connectDecidePolicySignal() { | |||||
| 571 | } | 596 | } | ||
| 572 | } | 597 | } | ||
| 573 | sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb) | 598 | sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb) | ||
| 574 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 599 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 575 | } | 600 | } | ||
| 576 | 601 | ||||
| 577 | // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads). | 602 | // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads). | ||
| @@ -770,7 +795,7 @@ func (wv *WebView) connectEnterFullscreenSignal() { | |||||
| 770 | return false // Allow fullscreen | 795 | return false // Allow fullscreen | ||
| 771 | } | 796 | } | ||
| 772 | sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb) | 797 | sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb) | ||
| 773 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 798 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 774 | } | 799 | } | ||
| 775 | 800 | ||||
| 776 | func (wv *WebView) connectLeaveFullscreenSignal() { | 801 | func (wv *WebView) connectLeaveFullscreenSignal() { | ||
| @@ -783,7 +808,7 @@ func (wv *WebView) connectLeaveFullscreenSignal() { | |||||
| 783 | return false // Allow leaving fullscreen | 808 | return false // Allow leaving fullscreen | ||
| 784 | } | 809 | } | ||
| 785 | sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb) | 810 | sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb) | ||
| 786 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 811 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 787 | } | 812 | } | ||
| 788 | 813 | ||||
| 789 | func (wv *WebView) connectAudioStateSignal() { | 814 | func (wv *WebView) connectAudioStateSignal() { | ||
| @@ -801,7 +826,7 @@ func (wv *WebView) connectAudioStateSignal() { | |||||
| 801 | } | 826 | } | ||
| 802 | } | 827 | } | ||
| 803 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb)) | 828 | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb)) | ||
| 804 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 829 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 805 | } | 830 | } | ||
| 806 | 831 | ||||
| 807 | func (wv *WebView) connectMouseTargetChangedSignal() { | 832 | func (wv *WebView) connectMouseTargetChangedSignal() { | ||
| @@ -829,7 +854,7 @@ func (wv *WebView) connectMouseTargetChangedSignal() { | |||||
| 829 | wv.OnLinkHover(uri) | 854 | wv.OnLinkHover(uri) | ||
| 830 | } | 855 | } | ||
| 831 | sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb) | 856 | sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb) | ||
| 832 | - | wv.signalIDs = append(wv.signalIDs, sigID) | 857 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) |
| 833 | } | 858 | } | ||
| 834 | 859 | ||||
| 835 | func (wv *WebView) connectBackForwardListChangedSignal() { | 860 | func (wv *WebView) connectBackForwardListChangedSignal() { | ||
| @@ -857,7 +882,52 @@ func (wv *WebView) connectBackForwardListChangedSignal() { | |||||
| 857 | } | 882 | } | ||
| 858 | sigID := backForwardList.ConnectChanged(&changedCb) | 883 | sigID := backForwardList.ConnectChanged(&changedCb) | ||
| 859 | wv.backForwardList = backForwardList | 884 | wv.backForwardList = backForwardList | ||
| 860 | - | wv.backForwardListSignalID = sigID | 885 | + | wv.backForwardListSignalID = uintptr(sigID) |
| 886 | + | } | |||
| 887 | + | ||||
| 888 | + | func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string { | |||
| 889 | + | switch reason { | |||
| 890 | + | case webkit.WebProcessCrashedValue: | |||
| 891 | + | return "crashed" | |||
| 892 | + | case webkit.WebProcessExceededMemoryLimitValue: | |||
| 893 | + | return "exceeded_memory" | |||
| 894 | + | case webkit.WebProcessTerminatedByApiValue: | |||
| 895 | + | return "terminated_by_api" | |||
| 896 | + | default: | |||
| 897 | + | return "unknown" | |||
| 898 | + | } | |||
| 899 | + | } | |||
| 900 | + | ||||
| 901 | + | func mapWebProcessTerminationReason(reason webkit.WebProcessTerminationReason) port.WebProcessTerminationReason { | |||
| 902 | + | switch reason { | |||
| 903 | + | case webkit.WebProcessCrashedValue: | |||
| 904 | + | return port.WebProcessTerminationCrashed | |||
| 905 | + | case webkit.WebProcessExceededMemoryLimitValue: | |||
| 906 | + | return port.WebProcessTerminationExceededMemory | |||
| 907 | + | case webkit.WebProcessTerminatedByApiValue: | |||
| 908 | + | return port.WebProcessTerminationByAPI | |||
| 909 | + | default: | |||
| 910 | + | return port.WebProcessTerminationUnknown | |||
| 911 | + | } | |||
| 912 | + | } | |||
| 913 | + | ||||
| 914 | + | func (wv *WebView) connectWebProcessTerminatedSignal() { | |||
| 915 | + | terminatedCb := func(_ webkit.WebView, reason webkit.WebProcessTerminationReason) { | |||
| 916 | + | uri := wv.URI() | |||
| 917 | + | reasonLabel := webProcessTerminationReasonString(reason) | |||
| 918 | + | wv.logger.Warn(). | |||
| 919 | + | Uint64("id", uint64(wv.id)). | |||
| 920 | + | Str("reason", reasonLabel). | |||
| 921 | + | Int("reason_code", int(reason)). | |||
| 922 | + | Str("uri", uri). | |||
| 923 | + | Msg("web process terminated") | |||
| 924 | + | ||||
| 925 | + | if wv.OnWebProcessTerminated != nil { | |||
| 926 | + | wv.OnWebProcessTerminated(reason, reasonLabel, uri) | |||
| 927 | + | } | |||
| 928 | + | } | |||
| 929 | + | sigID := wv.inner.ConnectWebProcessTerminated(&terminatedCb) | |||
| 930 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |||
| 861 | } | 931 | } | ||
| 862 | 932 | ||||
| 863 | // ID returns the unique identifier for this WebView. | 933 | // ID returns the unique identifier for this WebView. | ||
| @@ -907,6 +977,7 @@ func (wv *WebView) LoadURI(ctx context.Context, uri string) error { | |||||
| 907 | if wv.destroyed.Load() { | 977 | if wv.destroyed.Load() { | ||
| 908 | return fmt.Errorf("webview %d is destroyed", wv.id) | 978 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 909 | } | 979 | } | ||
| 980 | + | wv.navigationActive.Store(true) | |||
| 910 | wv.inner.LoadUri(uri) | 981 | wv.inner.LoadUri(uri) | ||
| 911 | logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI") | 982 | logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI") | ||
| 912 | return nil | 983 | return nil | ||
| @@ -917,6 +988,7 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error | |||||
| 917 | if wv.destroyed.Load() { | 988 | if wv.destroyed.Load() { | ||
| 918 | return fmt.Errorf("webview %d is destroyed", wv.id) | 989 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 919 | } | 990 | } | ||
| 991 | + | wv.navigationActive.Store(true) | |||
| 920 | var baseURIPtr *string | 992 | var baseURIPtr *string | ||
| 921 | if baseURI != "" { | 993 | if baseURI != "" { | ||
| 922 | baseURIPtr = &baseURI | 994 | baseURIPtr = &baseURI | ||
| @@ -1140,6 +1212,8 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1140 | wv.OnFaviconChanged = nil | 1212 | wv.OnFaviconChanged = nil | ||
| 1141 | wv.OnClose = nil | 1213 | wv.OnClose = nil | ||
| 1142 | wv.OnCreate = nil | 1214 | wv.OnCreate = nil | ||
| 1215 | + | wv.OnLinkHover = nil | |||
| 1216 | + | wv.OnWebProcessTerminated = nil | |||
| 1143 | return | 1217 | return | ||
| 1144 | } | 1218 | } | ||
| 1145 | 1219 | ||||
| @@ -1179,6 +1253,13 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||||
| 1179 | } | 1253 | } | ||
| 1180 | } | 1254 | } | ||
| 1181 | wv.OnLinkHover = callbacks.OnLinkHover | 1255 | wv.OnLinkHover = callbacks.OnLinkHover | ||
| 1256 | + | if callbacks.OnWebProcessTerminated != nil { | |||
| 1257 | + | wv.OnWebProcessTerminated = func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) { | |||
| 1258 | + | callbacks.OnWebProcessTerminated(mapWebProcessTerminationReason(reason), reasonLabel, uri) | |||
| 1259 | + | } | |||
| 1260 | + | } else { | |||
| 1261 | + | wv.OnWebProcessTerminated = nil | |||
| 1262 | + | } | |||
| 1182 | } | 1263 | } | ||
| 1183 | 1264 | ||||
| 1184 | // ShowDevTools opens the WebKit inspector/developer tools. | 1265 | // ShowDevTools opens the WebKit inspector/developer tools. | ||
| @@ -1215,6 +1296,16 @@ func (wv *WebView) IsDestroyed() bool { | |||||
| 1215 | return wv.destroyed.Load() | 1296 | return wv.destroyed.Load() | ||
| 1216 | } | 1297 | } | ||
| 1217 | 1298 | ||||
| 1299 | + | // IsRelated returns true when this WebView shares process/session with a parent popup. | |||
| 1300 | + | func (wv *WebView) IsRelated() bool { | |||
| 1301 | + | return wv.isRelated | |||
| 1302 | + | } | |||
| 1303 | + | ||||
| 1304 | + | // HasNavigationActivity reports whether this WebView has been used for content navigation. | |||
| 1305 | + | func (wv *WebView) HasNavigationActivity() bool { | |||
| 1306 | + | return wv.navigationActive.Load() | |||
| 1307 | + | } | |||
| 1308 | + | ||||
| 1218 | // Close triggers the close callback as if window.close() was called. | 1309 | // Close triggers the close callback as if window.close() was called. | ||
| 1219 | // This is used for programmatic popup closure (e.g., OAuth auto-close). | 1310 | // This is used for programmatic popup closure (e.g., OAuth auto-close). | ||
| 1220 | func (wv *WebView) Close() { | 1311 | func (wv *WebView) Close() { | ||
| @@ -1236,13 +1327,13 @@ func (wv *WebView) DisconnectSignals() { | |||||
| 1236 | 1327 | ||||
| 1237 | obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer()) | 1328 | obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer()) | ||
| 1238 | for _, sigID := range wv.signalIDs { | 1329 | for _, sigID := range wv.signalIDs { | ||
| 1239 | - | gobject.SignalHandlerDisconnect(obj, sigID) | 1330 | + | gobject.SignalHandlerDisconnect(obj, uint(sigID)) |
| 1240 | } | 1331 | } | ||
| 1241 | wv.signalIDs = wv.signalIDs[:0] // Clear the slice | 1332 | wv.signalIDs = wv.signalIDs[:0] // Clear the slice | ||
| 1242 | 1333 | ||||
| 1243 | if wv.backForwardList != nil && wv.backForwardListSignalID != 0 { | 1334 | if wv.backForwardList != nil && wv.backForwardListSignalID != 0 { | ||
| 1244 | bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer()) | 1335 | bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer()) | ||
| 1245 | - | gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID) | 1336 | + | gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID)) |
| 1246 | wv.backForwardListSignalID = 0 | 1337 | wv.backForwardListSignalID = 0 | ||
| 1247 | wv.backForwardList = nil | 1338 | wv.backForwardList = nil | ||
| 1248 | } | 1339 | } | ||
| @@ -1254,9 +1345,16 @@ func (wv *WebView) DisconnectSignals() { | |||||
| 1254 | // This must be called when a WebView is no longer needed to free GPU resources, | 1345 | // This must be called when a WebView is no longer needed to free GPU resources, | ||
| 1255 | // VA-API decoder contexts, and DMA-BUF buffers held by the web process. | 1346 | // VA-API decoder contexts, and DMA-BUF buffers held by the web process. | ||
| 1256 | func (wv *WebView) Destroy() { | 1347 | func (wv *WebView) Destroy() { | ||
| 1348 | + | wv.DestroyWithPolicy("") | |||
| 1349 | + | } | |||
| 1350 | + | ||||
| 1351 | + | // DestroyWithPolicy cleans up the WebView resources with explicit process policy. | |||
| 1352 | + | // Valid policies: auto, always, never. | |||
| 1353 | + | func (wv *WebView) DestroyWithPolicy(policy string) { | |||
| 1257 | if wv.destroyed.Swap(true) { | 1354 | if wv.destroyed.Swap(true) { | ||
| 1258 | return // Already destroyed | 1355 | return // Already destroyed | ||
| 1259 | } | 1356 | } | ||
| 1357 | + | policy = resolveTerminatePolicy(policy) | |||
| 1260 | 1358 | ||||
| 1261 | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup | 1359 | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup | ||
| 1262 | wv.DisconnectSignals() | 1360 | wv.DisconnectSignals() | ||
| @@ -1275,6 +1373,7 @@ func (wv *WebView) Destroy() { | |||||
| 1275 | wv.OnLeaveFullscreen = nil | 1373 | wv.OnLeaveFullscreen = nil | ||
| 1276 | wv.OnAudioStateChanged = nil | 1374 | wv.OnAudioStateChanged = nil | ||
| 1277 | wv.OnLinkHover = nil | 1375 | wv.OnLinkHover = nil | ||
| 1376 | + | wv.OnWebProcessTerminated = nil | |||
| 1278 | 1377 | ||||
| 1279 | // 3. Clear async callback references | 1378 | // 3. Clear async callback references | ||
| 1280 | wv.mu.Lock() | 1379 | wv.mu.Lock() | ||
| @@ -1287,11 +1386,9 @@ func (wv *WebView) Destroy() { | |||||
| 1287 | wv.inner.Unparent() | 1386 | wv.inner.Unparent() | ||
| 1288 | } | 1387 | } | ||
| 1289 | 1388 | ||||
| 1290 | - | // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts) | 1389 | + | // 5. Optionally terminate web process (skip for related popup views sharing parent process). |
| 1291 | - | // This is critical to prevent zombie processes that hold video decoder resources. | 1390 | + | terminate := shouldTerminateWebProcess(policy, wv.isRelated, wv.navigationActive.Load()) |
| 1292 | - | // IMPORTANT: Skip for related views (popups) - they share the web process with their | 1391 | + | if terminate && wv.inner != nil { |
| 1293 | - | // parent. Terminating the shared process would kill the parent WebView too! | |||
| 1294 | - | if !wv.isRelated && wv.inner != nil { | |||
| 1295 | wv.inner.TerminateWebProcess() | 1392 | wv.inner.TerminateWebProcess() | ||
| 1296 | } | 1393 | } | ||
| 1297 | 1394 | ||||
| @@ -1303,13 +1400,176 @@ func (wv *WebView) Destroy() { | |||||
| 1303 | wv.ucm = nil | 1400 | wv.ucm = nil | ||
| 1304 | wv.findController = nil | 1401 | wv.findController = nil | ||
| 1305 | 1402 | ||||
| 1306 | - | if wv.isRelated { | 1403 | + | wv.logger.Debug(). |
| 1307 | - | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("related webview destroyed (process shared with parent)") | 1404 | + | Uint64("id", uint64(wv.id)). |
| 1308 | - | } else { | 1405 | + | Bool("related", wv.isRelated). |
| 1309 | - | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated") | 1406 | + | Bool("navigation_active", wv.navigationActive.Load()). |
| 1407 | + | Bool("terminated_process", terminate). | |||
| 1408 | + | Str("terminate_policy", policy). | |||
| 1409 | + | Msg("webview destroyed") | |||
| 1410 | + | } | |||
| 1411 | + | ||||
| 1412 | + | // ResetForPoolReuse sanitizes callbacks/state so the WebView can be safely reused. | |||
| 1413 | + | func (wv *WebView) ResetForPoolReuse() { | |||
| 1414 | + | if wv == nil || wv.destroyed.Load() { | |||
| 1415 | + | return | |||
| 1416 | + | } | |||
| 1417 | + | ||||
| 1418 | + | wv.OnLoadChanged = nil | |||
| 1419 | + | wv.OnTitleChanged = nil | |||
| 1420 | + | wv.OnURIChanged = nil | |||
| 1421 | + | wv.OnProgressChanged = nil | |||
| 1422 | + | wv.OnFaviconChanged = nil | |||
| 1423 | + | wv.OnClose = nil | |||
| 1424 | + | wv.OnCreate = nil | |||
| 1425 | + | wv.OnReadyToShow = nil | |||
| 1426 | + | wv.OnLinkMiddleClick = nil | |||
| 1427 | + | wv.OnEnterFullscreen = nil | |||
| 1428 | + | wv.OnLeaveFullscreen = nil | |||
| 1429 | + | wv.OnAudioStateChanged = nil | |||
| 1430 | + | wv.OnLinkHover = nil | |||
| 1431 | + | wv.OnWebProcessTerminated = nil | |||
| 1432 | + | ||||
| 1433 | + | wv.mu.Lock() | |||
| 1434 | + | wv.uri = "" | |||
| 1435 | + | wv.title = "" | |||
| 1436 | + | wv.progress = 0 | |||
| 1437 | + | wv.canGoBack = false | |||
| 1438 | + | wv.canGoFwd = false | |||
| 1439 | + | wv.isLoading = false | |||
| 1440 | + | wv.asyncCallbacks = nil | |||
| 1441 | + | wv.runJSErrorStats = make(map[string]runJSErrorStat) | |||
| 1442 | + | wv.lastProgressUpdate.Store(0) | |||
| 1443 | + | wv.mu.Unlock() | |||
| 1444 | + | ||||
| 1445 | + | wv.isFullscreen.Store(false) | |||
| 1446 | + | wv.isPlayingAudio.Store(false) | |||
| 1447 | + | wv.navigationActive.Store(false) | |||
| 1448 | + | ||||
| 1449 | + | if wv.inner != nil { | |||
| 1450 | + | wv.inner.StopLoading() | |||
| 1451 | + | wv.inner.SetVisible(false) | |||
| 1452 | + | } | |||
| 1453 | + | } | |||
| 1454 | + | ||||
| 1455 | + | func resolveTerminatePolicy(raw string) string { | |||
| 1456 | + | value := strings.ToLower(strings.TrimSpace(raw)) | |||
| 1457 | + | if value == "" { | |||
| 1458 | + | value = strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_WEBVIEW_TERMINATE_POLICY"))) | |||
| 1459 | + | } | |||
| 1460 | + | switch value { | |||
| 1461 | + | case terminatePolicyAlways: | |||
| 1462 | + | return terminatePolicyAlways | |||
| 1463 | + | case terminatePolicyNever: | |||
| 1464 | + | return terminatePolicyNever | |||
| 1465 | + | case terminatePolicyAuto, "": | |||
| 1466 | + | return terminatePolicyAuto | |||
| 1467 | + | default: | |||
| 1468 | + | return terminatePolicyAuto | |||
| 1310 | } | 1469 | } | ||
| 1311 | } | 1470 | } | ||
| 1312 | 1471 | ||||
| 1472 | + | func shouldTerminateWebProcess(policy string, isRelated, navigationActive bool) bool { | |||
| 1473 | + | if isRelated { | |||
| 1474 | + | return false | |||
| 1475 | + | } | |||
| 1476 | + | switch resolveTerminatePolicy(policy) { | |||
| 1477 | + | case terminatePolicyNever: | |||
| 1478 | + | return false | |||
| 1479 | + | case terminatePolicyAlways: | |||
| 1480 | + | return true | |||
| 1481 | + | case terminatePolicyAuto: | |||
| 1482 | + | return navigationActive | |||
| 1483 | + | default: | |||
| 1484 | + | return navigationActive | |||
| 1485 | + | } | |||
| 1486 | + | } | |||
| 1487 | + | ||||
| 1488 | + | func classifyRunJSEvaluateError(err error) (nonFatal bool, signature string) { | |||
| 1489 | + | // Defensive guard: callers should only pass non-nil errors, but tolerate nil safely. | |||
| 1490 | + | if err == nil { | |||
| 1491 | + | return true, "" | |||
| 1492 | + | } | |||
| 1493 | + | msg := strings.ToLower(strings.TrimSpace(err.Error())) | |||
| 1494 | + | signature = "evaluate_error:" + normalizeRunJSErrorSignature(msg) | |||
| 1495 | + | switch { | |||
| 1496 | + | case strings.Contains(msg, "canceled"), | |||
| 1497 | + | strings.Contains(msg, "cancel"), | |||
| 1498 | + | strings.Contains(msg, "javascript execution context"), | |||
| 1499 | + | strings.Contains(msg, "execution context was destroyed"), | |||
| 1500 | + | strings.Contains(msg, "document unloaded"): | |||
| 1501 | + | return true, signature | |||
| 1502 | + | default: | |||
| 1503 | + | return false, signature | |||
| 1504 | + | } | |||
| 1505 | + | } | |||
| 1506 | + | ||||
| 1507 | + | func normalizeRunJSErrorSignature(msg string) string { | |||
| 1508 | + | msg = strings.ToLower(strings.TrimSpace(msg)) | |||
| 1509 | + | if msg == "" { | |||
| 1510 | + | return "empty" | |||
| 1511 | + | } | |||
| 1512 | + | fields := strings.Fields(msg) | |||
| 1513 | + | if len(fields) == 0 { | |||
| 1514 | + | return "empty" | |||
| 1515 | + | } | |||
| 1516 | + | return strings.Join(fields, " ") | |||
| 1517 | + | } | |||
| 1518 | + | ||||
| 1519 | + | func (wv *WebView) runJSDomain() string { | |||
| 1520 | + | wv.mu.RLock() | |||
| 1521 | + | currentURI := strings.TrimSpace(wv.uri) | |||
| 1522 | + | wv.mu.RUnlock() | |||
| 1523 | + | if currentURI == "" { | |||
| 1524 | + | return runJSUnknown | |||
| 1525 | + | } | |||
| 1526 | + | parsed, err := url.Parse(currentURI) | |||
| 1527 | + | if err != nil { | |||
| 1528 | + | return runJSUnknown | |||
| 1529 | + | } | |||
| 1530 | + | host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) | |||
| 1531 | + | if host == "" { | |||
| 1532 | + | return runJSUnknown | |||
| 1533 | + | } | |||
| 1534 | + | return host | |||
| 1535 | + | } | |||
| 1536 | + | ||||
| 1537 | + | func (wv *WebView) shouldLogRunJSError(domain, signature string, nonFatal bool, now time.Time) (bool, uint64) { | |||
| 1538 | + | if wv == nil { | |||
| 1539 | + | return true, 1 | |||
| 1540 | + | } | |||
| 1541 | + | if now.IsZero() { | |||
| 1542 | + | now = time.Now() | |||
| 1543 | + | } | |||
| 1544 | + | if domain == "" { | |||
| 1545 | + | domain = runJSUnknown | |||
| 1546 | + | } | |||
| 1547 | + | if signature == "" { | |||
| 1548 | + | signature = runJSUnknown | |||
| 1549 | + | } | |||
| 1550 | + | key := domain + "|" + signature | |||
| 1551 | + | ||||
| 1552 | + | wv.mu.Lock() | |||
| 1553 | + | if wv.runJSErrorStats == nil { | |||
| 1554 | + | wv.runJSErrorStats = make(map[string]runJSErrorStat) | |||
| 1555 | + | } | |||
| 1556 | + | stat := wv.runJSErrorStats[key] | |||
| 1557 | + | stat.count++ | |||
| 1558 | + | count := stat.count | |||
| 1559 | + | ||||
| 1560 | + | shouldLog := true | |||
| 1561 | + | if nonFatal { | |||
| 1562 | + | shouldLog = count == 1 || count%runJSAggregateLogEvery == 0 || now.Sub(stat.lastLog) >= runJSNonFatalLogInterval | |||
| 1563 | + | } | |||
| 1564 | + | if shouldLog { | |||
| 1565 | + | stat.lastLog = now | |||
| 1566 | + | } | |||
| 1567 | + | wv.runJSErrorStats[key] = stat | |||
| 1568 | + | wv.mu.Unlock() | |||
| 1569 | + | ||||
| 1570 | + | return shouldLog, count | |||
| 1571 | + | } | |||
| 1572 | + | ||||
| 1313 | // RunJavaScript executes script in the specified world (empty for main world). | 1573 | // RunJavaScript executes script in the specified world (empty for main world). | ||
| 1314 | // This is fire-and-forget: it does not block and errors are logged asynchronously. | 1574 | // This is fire-and-forget: it does not block and errors are logged asynchronously. | ||
| 1315 | // Safe to call from any context including GTK signal handlers. | 1575 | // Safe to call from any context including GTK signal handlers. | ||
| @@ -1324,25 +1584,68 @@ func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) | |||||
| 1324 | log := logging.FromContext(ctx) | 1584 | log := logging.FromContext(ctx) | ||
| 1325 | 1585 | ||||
| 1326 | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | 1586 | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | ||
| 1587 | + | if wv.destroyed.Load() { | |||
| 1588 | + | return | |||
| 1589 | + | } | |||
| 1590 | + | wv.mu.RLock() | |||
| 1591 | + | inner := wv.inner | |||
| 1592 | + | wv.mu.RUnlock() | |||
| 1593 | + | if inner == nil { | |||
| 1594 | + | return | |||
| 1595 | + | } | |||
| 1596 | + | ||||
| 1597 | + | domain := wv.runJSDomain() | |||
| 1327 | if resPtr == 0 { | 1598 | if resPtr == 0 { | ||
| 1328 | - | log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result") | 1599 | + | signature := "nil_async_result" |
| 1600 | + | shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now()) | |||
| 1601 | + | if shouldLog { | |||
| 1602 | + | log.Debug(). | |||
| 1603 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 1604 | + | Str("domain", domain). | |||
| 1605 | + | Str("signature", signature). | |||
| 1606 | + | Uint64("repeat_count", count). | |||
| 1607 | + | Msg("RunJavaScript: non-fatal nil async result") | |||
| 1608 | + | } | |||
| 1329 | return | 1609 | return | ||
| 1330 | } | 1610 | } | ||
| 1331 | 1611 | ||||
| 1332 | res := &gio.AsyncResultBase{Ptr: resPtr} | 1612 | res := &gio.AsyncResultBase{Ptr: resPtr} | ||
| 1333 | - | value, err := wv.inner.EvaluateJavascriptFinish(res) | 1613 | + | value, err := inner.EvaluateJavascriptFinish(res) |
| 1334 | if err != nil { | 1614 | if err != nil { | ||
| 1335 | - | log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed") | 1615 | + | nonFatal, signature := classifyRunJSEvaluateError(err) |
| 1616 | + | shouldLog, count := wv.shouldLogRunJSError(domain, signature, nonFatal, time.Now()) | |||
| 1617 | + | if shouldLog { | |||
| 1618 | + | ev := log.Warn() | |||
| 1619 | + | msg := "RunJavaScript: failed" | |||
| 1620 | + | if nonFatal { | |||
| 1621 | + | ev = log.Debug() | |||
| 1622 | + | msg = "RunJavaScript: non-fatal failure" | |||
| 1623 | + | } | |||
| 1624 | + | ev.Err(err). | |||
| 1625 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 1626 | + | Str("domain", domain). | |||
| 1627 | + | Str("signature", signature). | |||
| 1628 | + | Uint64("repeat_count", count). | |||
| 1629 | + | Msg(msg) | |||
| 1630 | + | } | |||
| 1336 | return | 1631 | return | ||
| 1337 | } | 1632 | } | ||
| 1338 | 1633 | ||||
| 1339 | if value != nil { | 1634 | if value != nil { | ||
| 1340 | if jscCtx := value.GetContext(); jscCtx != nil { | 1635 | if jscCtx := value.GetContext(); jscCtx != nil { | ||
| 1341 | if exc := jscCtx.GetException(); exc != nil { | 1636 | if exc := jscCtx.GetException(); exc != nil { | ||
| 1342 | - | log.Warn(). | 1637 | + | exceptionMessage := strings.TrimSpace(exc.GetMessage()) |
| 1343 | - | Str("exception", exc.GetMessage()). | 1638 | + | signature := "js_exception:" + normalizeRunJSErrorSignature(exceptionMessage) |
| 1344 | - | Uint64("webview_id", uint64(wv.id)). | 1639 | + | shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now()) |
| 1345 | - | Msg("RunJavaScript: JS exception") | 1640 | + | if shouldLog { |
| 1641 | + | log.Debug(). | |||
| 1642 | + | Str("exception", exceptionMessage). | |||
| 1643 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 1644 | + | Str("domain", domain). | |||
| 1645 | + | Str("signature", signature). | |||
| 1646 | + | Uint64("repeat_count", count). | |||
| 1647 | + | Msg("RunJavaScript: JS exception") | |||
| 1648 | + | } | |||
| 1346 | } | 1649 | } | ||
| 1347 | } | 1650 | } | ||
| 1348 | } | 1651 | } | ||
| @@ -1359,7 +1662,13 @@ func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) | |||||
| 1359 | if worldName != "" { | 1662 | if worldName != "" { | ||
| 1360 | worldNamePtr = &worldName | 1663 | worldNamePtr = &worldName | ||
| 1361 | } | 1664 | } | ||
| 1362 | - | wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0) | 1665 | + | wv.mu.RLock() |
| 1666 | + | inner := wv.inner | |||
| 1667 | + | wv.mu.RUnlock() | |||
| 1668 | + | if inner == nil || wv.destroyed.Load() { | |||
| 1669 | + | return | |||
| 1670 | + | } | |||
| 1671 | + | inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0) | |||
| 1363 | } | 1672 | } | ||
| 1364 | 1673 | ||||
| 1365 | // AttachFrontend injects scripts/styles and wires the message router once per WebView. | 1674 | // AttachFrontend injects scripts/styles and wires the message router once per WebView. | ||
A internal/infrastructure/webkit/webview_runjavascript_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestClassifyRunJSEvaluateError(t *testing.T) { | |||
| 12 | + | t.Run("non-fatal canceled", func(t *testing.T) { | |||
| 13 | + | nonFatal, signature := classifyRunJSEvaluateError(errors.New("Operation was canceled")) | |||
| 14 | + | assert.True(t, nonFatal) | |||
| 15 | + | assert.Equal(t, "evaluate_error:operation was canceled", signature) | |||
| 16 | + | }) | |||
| 17 | + | ||||
| 18 | + | t.Run("non-fatal context destroyed", func(t *testing.T) { | |||
| 19 | + | nonFatal, signature := classifyRunJSEvaluateError(errors.New("JavaScript execution context was destroyed")) | |||
| 20 | + | assert.True(t, nonFatal) | |||
| 21 | + | assert.Equal(t, "evaluate_error:javascript execution context was destroyed", signature) | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | t.Run("fatal unknown", func(t *testing.T) { | |||
| 25 | + | nonFatal, signature := classifyRunJSEvaluateError(errors.New("segmentation fault in JS runtime")) | |||
| 26 | + | assert.False(t, nonFatal) | |||
| 27 | + | assert.Equal(t, "evaluate_error:segmentation fault in js runtime", signature) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestNormalizeRunJSErrorSignature(t *testing.T) { | |||
| 32 | + | assert.Equal(t, "empty", normalizeRunJSErrorSignature(" \n\t ")) | |||
| 33 | + | assert.Equal(t, "foo bar baz", normalizeRunJSErrorSignature(" Foo BAR\tbaz ")) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestWebViewRunJSDomain(t *testing.T) { | |||
| 37 | + | wv := &WebView{uri: "https://Sub.Example.com/path?q=1"} | |||
| 38 | + | assert.Equal(t, "sub.example.com", wv.runJSDomain()) | |||
| 39 | + | ||||
| 40 | + | wv = &WebView{uri: "not a uri"} | |||
| 41 | + | assert.Equal(t, "unknown", wv.runJSDomain()) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestShouldLogRunJSError_NonFatalAggregation(t *testing.T) { | |||
| 45 | + | wv := &WebView{} | |||
| 46 | + | now := time.Now() | |||
| 47 | + | ||||
| 48 | + | shouldLog, count := wv.shouldLogRunJSError("example.com", "sig", true, now) | |||
| 49 | + | assert.True(t, shouldLog) | |||
| 50 | + | assert.Equal(t, uint64(1), count) | |||
| 51 | + | ||||
| 52 | + | shouldLog, count = wv.shouldLogRunJSError("example.com", "sig", true, now.Add(10*time.Second)) | |||
| 53 | + | assert.False(t, shouldLog) | |||
| 54 | + | assert.Equal(t, uint64(2), count) | |||
| 55 | + | ||||
| 56 | + | shouldLog, count = wv.shouldLogRunJSError("example.com", "sig", true, now.Add(31*time.Second)) | |||
| 57 | + | assert.True(t, shouldLog) | |||
| 58 | + | assert.Equal(t, uint64(3), count) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestShouldLogRunJSError_NonFatalLogsEveryN(t *testing.T) { | |||
| 62 | + | wv := &WebView{} | |||
| 63 | + | base := time.Now() | |||
| 64 | + | for i := 1; i < runJSAggregateLogEvery; i++ { | |||
| 65 | + | shouldLog, _ := wv.shouldLogRunJSError("example.com", "sig", true, base) | |||
| 66 | + | if i == 1 { | |||
| 67 | + | assert.True(t, shouldLog) | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | assert.False(t, shouldLog) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | shouldLog, count := wv.shouldLogRunJSError("example.com", "sig", true, base) | |||
| 74 | + | assert.True(t, shouldLog) | |||
| 75 | + | assert.Equal(t, uint64(runJSAggregateLogEvery), count) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) { | |||
| 79 | + | wv := &WebView{} | |||
| 80 | + | base := time.Now() | |||
| 81 | + | ||||
| 82 | + | shouldLog, count := wv.shouldLogRunJSError("example.com", "fatal-sig", false, base) | |||
| 83 | + | assert.True(t, shouldLog) | |||
| 84 | + | assert.Equal(t, uint64(1), count) | |||
| 85 | + | ||||
| 86 | + | shouldLog, count = wv.shouldLogRunJSError("example.com", "fatal-sig", false, base) | |||
| 87 | + | assert.True(t, shouldLog) | |||
| 88 | + | assert.Equal(t, uint64(2), count) | |||
| 89 | + | } | |||
A internal/infrastructure/webkit/webview_termination_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWebProcessTerminationReasonString(t *testing.T) { | |||
| 11 | + | assert.Equal(t, "crashed", webProcessTerminationReasonString(webkitlib.WebProcessCrashedValue)) | |||
| 12 | + | assert.Equal(t, "exceeded_memory", webProcessTerminationReasonString(webkitlib.WebProcessExceededMemoryLimitValue)) | |||
| 13 | + | assert.Equal(t, "terminated_by_api", webProcessTerminationReasonString(webkitlib.WebProcessTerminatedByApiValue)) | |||
| 14 | + | assert.Equal(t, "unknown", webProcessTerminationReasonString(webkitlib.WebProcessTerminationReason(99))) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestResolveTerminatePolicy(t *testing.T) { | |||
| 18 | + | t.Setenv("DUMBER_WEBVIEW_TERMINATE_POLICY", "") | |||
| 19 | + | assert.Equal(t, terminatePolicyAuto, resolveTerminatePolicy("")) | |||
| 20 | + | assert.Equal(t, terminatePolicyAlways, resolveTerminatePolicy("always")) | |||
| 21 | + | assert.Equal(t, terminatePolicyNever, resolveTerminatePolicy("never")) | |||
| 22 | + | assert.Equal(t, terminatePolicyAuto, resolveTerminatePolicy("invalid")) | |||
| 23 | + | ||||
| 24 | + | t.Setenv("DUMBER_WEBVIEW_TERMINATE_POLICY", "always") | |||
| 25 | + | assert.Equal(t, terminatePolicyAlways, resolveTerminatePolicy("")) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestShouldTerminateWebProcess(t *testing.T) { | |||
| 29 | + | assert.False(t, shouldTerminateWebProcess(terminatePolicyAlways, true, true)) // isRelated=true, navigationActive=true | |||
| 30 | + | assert.False(t, shouldTerminateWebProcess(terminatePolicyNever, false, true)) // isRelated=false, navigationActive=true | |||
| 31 | + | assert.True(t, shouldTerminateWebProcess(terminatePolicyAlways, false, false)) // isRelated=false, navigationActive=false | |||
| 32 | + | assert.True(t, shouldTerminateWebProcess(terminatePolicyAuto, false, true)) // isRelated=false, navigationActive=true | |||
| 33 | + | assert.False(t, shouldTerminateWebProcess(terminatePolicyAuto, false, false)) // isRelated=false, navigationActive=false | |||
| 34 | + | } | |||
M internal/ui/adapter/favicon.go +64 -4
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/jwijenbergh/puregotk/v4/gdk" | 12 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 13 | "github.com/jwijenbergh/puregotk/v4/gio" | 13 | "github.com/jwijenbergh/puregotk/v4/gio" | ||
| 14 | "github.com/jwijenbergh/puregotk/v4/glib" | 14 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 15 | + | "github.com/rs/zerolog" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | // FaviconAdapter bridges the domain FaviconService to GTK by providing | 18 | // FaviconAdapter bridges the domain FaviconService to GTK by providing | ||
| @@ -21,8 +22,12 @@ type FaviconAdapter struct { | |||||
| 21 | faviconDB *webkit.FaviconDatabase | 22 | faviconDB *webkit.FaviconDatabase | ||
| 22 | textureCache map[string]*gdk.Texture | 23 | textureCache map[string]*gdk.Texture | ||
| 23 | mu sync.RWMutex | 24 | mu sync.RWMutex | ||
| 25 | + | warnMu sync.Mutex | |||
| 26 | + | warnCounts map[string]int | |||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 29 | + | type warningLogFunc func(log *zerolog.Logger, err error) | |||
| 30 | + | ||||
| 26 | // NewFaviconAdapter creates a new FaviconAdapter. | 31 | // NewFaviconAdapter creates a new FaviconAdapter. | ||
| 27 | // The faviconDB can be nil if WebKit favicon database is not available. | 32 | // The faviconDB can be nil if WebKit favicon database is not available. | ||
| 28 | func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter { | 33 | func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter { | ||
| @@ -30,6 +35,7 @@ func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDataba | |||||
| 30 | service: service, | 35 | service: service, | ||
| 31 | faviconDB: faviconDB, | 36 | faviconDB: faviconDB, | ||
| 32 | textureCache: make(map[string]*gdk.Texture), | 37 | textureCache: make(map[string]*gdk.Texture), | ||
| 38 | + | warnCounts: make(map[string]int), | |||
| 33 | } | 39 | } | ||
| 34 | } | 40 | } | ||
| 35 | 41 | ||||
| @@ -195,6 +201,9 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin( | |||||
| 195 | // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking. | 201 | // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking. | ||
| 196 | func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) { | 202 | func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) { | ||
| 197 | log := logging.FromContext(ctx) | 203 | log := logging.FromContext(ctx) | ||
| 204 | + | cacheDirWarnKey := "cache-dir" | |||
| 205 | + | savePNGWarnKey := "save-png:" + domain | |||
| 206 | + | sizedPNGWarnKey := "sized-png:" + domain | |||
| 198 | 207 | ||||
| 199 | // Check if PNG save is needed before scheduling idle callback | 208 | // Check if PNG save is needed before scheduling idle callback | ||
| 200 | pngPath := a.service.DiskPathPNG(domain) | 209 | pngPath := a.service.DiskPathPNG(domain) | ||
| @@ -203,8 +212,12 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||||
| 203 | if needsPNGSave { | 212 | if needsPNGSave { | ||
| 204 | // Ensure cache directory exists (cheap check, do it now) | 213 | // Ensure cache directory exists (cheap check, do it now) | ||
| 205 | if err := a.service.EnsureCacheDir(); err != nil { | 214 | if err := a.service.EnsureCacheDir(); err != nil { | ||
| 206 | - | log.Warn().Err(err).Str("domain", domain).Msg("failed to create favicon cache dir") | 215 | + | a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) { |
| 216 | + | log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir") | |||
| 217 | + | }) | |||
| 207 | needsPNGSave = false | 218 | needsPNGSave = false | ||
| 219 | + | } else { | |||
| 220 | + | a.clearWarningDedup(cacheDirWarnKey) | |||
| 208 | } | 221 | } | ||
| 209 | } | 222 | } | ||
| 210 | 223 | ||||
| @@ -213,15 +226,21 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||||
| 213 | // texture.SaveToPng must run on main thread but we defer it to avoid blocking | 226 | // texture.SaveToPng must run on main thread but we defer it to avoid blocking | ||
| 214 | cb := glib.SourceFunc(func(_ uintptr) bool { | 227 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 215 | if ok := texture.SaveToPng(pngPath); !ok { | 228 | if ok := texture.SaveToPng(pngPath); !ok { | ||
| 216 | - | log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG") | 229 | + | a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) { |
| 230 | + | log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG") | |||
| 231 | + | }) | |||
| 217 | } else { | 232 | } else { | ||
| 233 | + | a.clearWarningDedup(savePNGWarnKey) | |||
| 218 | log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG") | 234 | log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG") | ||
| 219 | 235 | ||||
| 220 | // Create normalized sized copy for dmenu/fuzzel (async) | 236 | // Create normalized sized copy for dmenu/fuzzel (async) | ||
| 221 | go func() { | 237 | go func() { | ||
| 222 | if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil { | 238 | if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil { | ||
| 223 | - | log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG") | 239 | + | a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) { |
| 240 | + | log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG") | |||
| 241 | + | }) | |||
| 224 | } else { | 242 | } else { | ||
| 243 | + | a.clearWarningDedup(sizedPNGWarnKey) | |||
| 225 | log.Debug().Str("domain", domain).Msg("created sized PNG") | 244 | log.Debug().Str("domain", domain).Msg("created sized PNG") | ||
| 226 | } | 245 | } | ||
| 227 | }() | 246 | }() | ||
| @@ -318,13 +337,54 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) { | |||||
| 318 | if hasPNG && !hasSized { | 337 | if hasPNG && !hasSized { | ||
| 319 | log.Debug().Str("domain", domain).Msg("creating sized PNG") | 338 | log.Debug().Str("domain", domain).Msg("creating sized PNG") | ||
| 320 | if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil { | 339 | if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil { | ||
| 321 | - | log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG") | 340 | + | a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) { |
| 341 | + | log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG") | |||
| 342 | + | }) | |||
| 322 | } else { | 343 | } else { | ||
| 344 | + | a.clearWarningDedup("sized-png:" + domain) | |||
| 323 | log.Debug().Str("domain", domain).Msg("sized PNG created successfully") | 345 | log.Debug().Str("domain", domain).Msg("sized PNG created successfully") | ||
| 324 | } | 346 | } | ||
| 325 | } | 347 | } | ||
| 326 | } | 348 | } | ||
| 327 | 349 | ||||
| 350 | + | func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) { | |||
| 351 | + | a.warnMu.Lock() | |||
| 352 | + | defer a.warnMu.Unlock() | |||
| 353 | + | ||||
| 354 | + | count := a.warnCounts[key] + 1 | |||
| 355 | + | a.warnCounts[key] = count | |||
| 356 | + | ||||
| 357 | + | return count == 1, count - 1 | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func (a *FaviconAdapter) clearWarningDedup(key string) { | |||
| 361 | + | a.warnMu.Lock() | |||
| 362 | + | delete(a.warnCounts, key) | |||
| 363 | + | a.warnMu.Unlock() | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func (a *FaviconAdapter) logWarningDedup( | |||
| 367 | + | ctx context.Context, | |||
| 368 | + | key string, | |||
| 369 | + | err error, | |||
| 370 | + | warnFn warningLogFunc, | |||
| 371 | + | ) { | |||
| 372 | + | log := logging.FromContext(ctx) | |||
| 373 | + | logWarning, suppressed := a.shouldLogWarningDedup(key) | |||
| 374 | + | if logWarning { | |||
| 375 | + | warnFn(log, err) | |||
| 376 | + | return | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | if suppressed == 1 { | |||
| 380 | + | event := log.Debug() | |||
| 381 | + | if err != nil { | |||
| 382 | + | event = event.Err(err) | |||
| 383 | + | } | |||
| 384 | + | event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning") | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 328 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | 388 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | ||
| 329 | // The texture is cached under the InternalDomain key. | 389 | // The texture is cached under the InternalDomain key. | ||
| 330 | func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | 390 | func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | ||
A internal/ui/adapter/favicon_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package adapter | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync/atomic" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/rs/zerolog" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) { | |||
| 11 | + | adapter := NewFaviconAdapter(nil, nil) | |||
| 12 | + | ||||
| 13 | + | first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com") | |||
| 14 | + | if !first { | |||
| 15 | + | t.Fatalf("expected first warning to be logged") | |||
| 16 | + | } | |||
| 17 | + | if suppressed != 0 { | |||
| 18 | + | t.Fatalf("expected no suppressed count on first warning, got %d", suppressed) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | first, suppressed = adapter.shouldLogWarningDedup("save-png:example.com") | |||
| 22 | + | if first { | |||
| 23 | + | t.Fatalf("expected repeated warning to be suppressed") | |||
| 24 | + | } | |||
| 25 | + | if suppressed != 1 { | |||
| 26 | + | t.Fatalf("expected suppressed count to be 1, got %d", suppressed) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | first, suppressed = adapter.shouldLogWarningDedup("save-png:example.com") | |||
| 30 | + | if first { | |||
| 31 | + | t.Fatalf("expected repeated warning to remain suppressed") | |||
| 32 | + | } | |||
| 33 | + | if suppressed != 2 { | |||
| 34 | + | t.Fatalf("expected suppressed count to be 2, got %d", suppressed) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestFaviconWarningDedup_ClearResetsState(t *testing.T) { | |||
| 39 | + | adapter := NewFaviconAdapter(nil, nil) | |||
| 40 | + | key := "sized-png:example.com" | |||
| 41 | + | ||||
| 42 | + | first, _ := adapter.shouldLogWarningDedup(key) | |||
| 43 | + | if !first { | |||
| 44 | + | t.Fatalf("expected first warning to be logged") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | adapter.clearWarningDedup(key) | |||
| 48 | + | ||||
| 49 | + | first, suppressed := adapter.shouldLogWarningDedup(key) | |||
| 50 | + | if !first { | |||
| 51 | + | t.Fatalf("expected warning to log again after clear") | |||
| 52 | + | } | |||
| 53 | + | if suppressed != 0 { | |||
| 54 | + | t.Fatalf("expected suppressed count reset after clear, got %d", suppressed) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) { | |||
| 59 | + | adapter := NewFaviconAdapter(nil, nil) | |||
| 60 | + | key := "save-png:example.com" | |||
| 61 | + | var calls atomic.Int32 | |||
| 62 | + | ||||
| 63 | + | adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) { | |||
| 64 | + | calls.Add(1) | |||
| 65 | + | }) | |||
| 66 | + | adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) { | |||
| 67 | + | calls.Add(1) | |||
| 68 | + | }) | |||
| 69 | + | ||||
| 70 | + | if got := calls.Load(); got != 1 { | |||
| 71 | + | t.Fatalf("expected warning callback to run once, got %d", got) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M internal/ui/app.go +3 -0
| @@ -1393,6 +1393,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1393 | a.deps.NavigateUC, | 1393 | a.deps.NavigateUC, | ||
| 1394 | a.contentCoord, | 1394 | a.contentCoord, | ||
| 1395 | ) | 1395 | ) | ||
| 1396 | + | a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) { | |||
| 1397 | + | a.navCoord.ClearPaneHistory(paneID) | |||
| 1398 | + | }) | |||
| 1396 | 1399 | ||||
| 1397 | // Wire title updates to history persistence | 1400 | // Wire title updates to history persistence | ||
| 1398 | a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) { | 1401 | a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) { | ||
M internal/ui/component/loading_skeleton.go +42 -12
| @@ -1,25 +1,38 @@ | |||||
| 1 | package component | 1 | package component | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync/atomic" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/dumber/internal/ui/layout" | 6 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 5 | "github.com/jwijenbergh/puregotk/v4/gtk" | 7 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | // LoadingSkeleton displays a themed loading placeholder. | 10 | // LoadingSkeleton displays a themed loading placeholder. | ||
| 9 | // It is intended to be embedded as an overlay while primary content is loading. | 11 | // It is intended to be embedded as an overlay while primary content is loading. | ||
| 10 | - | // Shows a faded app logo and spinner. | 12 | + | // Shows a faded app logo, a discrete spinner, and the version below. |
| 11 | type LoadingSkeleton struct { | 13 | type LoadingSkeleton struct { | ||
| 12 | container layout.BoxWidget | 14 | container layout.BoxWidget | ||
| 13 | content layout.BoxWidget | 15 | content layout.BoxWidget | ||
| 14 | spinner layout.SpinnerWidget | 16 | spinner layout.SpinnerWidget | ||
| 15 | logo layout.ImageWidget | 17 | logo layout.ImageWidget | ||
| 18 | + | version layout.LabelWidget | |||
| 16 | } | 19 | } | ||
| 17 | 20 | ||||
| 18 | const ( | 21 | const ( | ||
| 19 | - | loadingSkeletonSpacing = 6 | 22 | + | loadingSkeletonSpacing = 6 |
| 20 | - | loadingSkeletonLogoSize = 512 | 23 | + | loadingSkeletonLogoSize = 256 |
| 24 | + | loadingSkeletonSpinnerSize = 32 | |||
| 25 | + | loadingSkeletonVersionMaxChars = 16 | |||
| 21 | ) | 26 | ) | ||
| 22 | 27 | ||||
| 28 | + | // skeletonVersion is set via SetSkeletonVersion during startup. | |||
| 29 | + | var skeletonVersion atomic.Value | |||
| 30 | + | ||||
| 31 | + | // SetSkeletonVersion sets the version string shown on loading skeletons. | |||
| 32 | + | func SetSkeletonVersion(v string) { | |||
| 33 | + | skeletonVersion.Store(v) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 23 | func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | 36 | func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | ||
| 24 | container := factory.NewBox(layout.OrientationVertical, 0) | 37 | container := factory.NewBox(layout.OrientationVertical, 0) | ||
| 25 | container.SetHexpand(true) | 38 | container.SetHexpand(true) | ||
| @@ -37,14 +50,6 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |||||
| 37 | content.SetCanTarget(false) | 50 | content.SetCanTarget(false) | ||
| 38 | content.AddCssClass("loading-skeleton-content") | 51 | content.AddCssClass("loading-skeleton-content") | ||
| 39 | 52 | ||||
| 40 | - | spinner := factory.NewSpinner() | |||
| 41 | - | spinner.SetHalign(gtk.AlignCenterValue) | |||
| 42 | - | spinner.SetValign(gtk.AlignCenterValue) | |||
| 43 | - | spinner.SetCanFocus(false) | |||
| 44 | - | spinner.SetCanTarget(false) | |||
| 45 | - | spinner.SetSizeRequest(32, 32) | |||
| 46 | - | spinner.AddCssClass("loading-skeleton-spinner") | |||
| 47 | - | ||||
| 48 | // Faded app logo | 53 | // Faded app logo | ||
| 49 | logo := factory.NewImage() | 54 | logo := factory.NewImage() | ||
| 50 | logo.SetHalign(gtk.AlignCenterValue) | 55 | logo.SetHalign(gtk.AlignCenterValue) | ||
| @@ -60,9 +65,33 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |||||
| 60 | logo.SetFromPaintable(logoTexture) | 65 | logo.SetFromPaintable(logoTexture) | ||
| 61 | } | 66 | } | ||
| 62 | 67 | ||||
| 63 | - | // Layout: logo, spinner (vertically centered) | 68 | + | // Discrete spinner between logo and version |
| 69 | + | spinner := factory.NewSpinner() | |||
| 70 | + | spinner.SetHalign(gtk.AlignCenterValue) | |||
| 71 | + | spinner.SetValign(gtk.AlignCenterValue) | |||
| 72 | + | spinner.SetCanFocus(false) | |||
| 73 | + | spinner.SetCanTarget(false) | |||
| 74 | + | spinner.SetSizeRequest(loadingSkeletonSpinnerSize, loadingSkeletonSpinnerSize) | |||
| 75 | + | spinner.AddCssClass("loading-skeleton-spinner") | |||
| 76 | + | ||||
| 77 | + | // Version label below spinner | |||
| 78 | + | versionText, _ := skeletonVersion.Load().(string) | |||
| 79 | + | if versionText == "" { | |||
| 80 | + | versionText = "dev" | |||
| 81 | + | } | |||
| 82 | + | versionLabel := factory.NewLabel(versionText) | |||
| 83 | + | versionLabel.SetHalign(gtk.AlignCenterValue) | |||
| 84 | + | versionLabel.SetValign(gtk.AlignCenterValue) | |||
| 85 | + | versionLabel.SetCanFocus(false) | |||
| 86 | + | versionLabel.SetCanTarget(false) | |||
| 87 | + | versionLabel.SetMaxWidthChars(loadingSkeletonVersionMaxChars) | |||
| 88 | + | versionLabel.SetEllipsize(layout.EllipsizeEnd) | |||
| 89 | + | versionLabel.AddCssClass("loading-skeleton-version") | |||
| 90 | + | ||||
| 91 | + | // Layout: logo, spinner, version (vertically centered) | |||
| 64 | content.Append(logo) | 92 | content.Append(logo) | ||
| 65 | content.Append(spinner) | 93 | content.Append(spinner) | ||
| 94 | + | content.Append(versionLabel) | |||
| 66 | container.Append(content) | 95 | container.Append(content) | ||
| 67 | 96 | ||||
| 68 | ls := &LoadingSkeleton{ | 97 | ls := &LoadingSkeleton{ | ||
| @@ -70,6 +99,7 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |||||
| 70 | content: content, | 99 | content: content, | ||
| 71 | spinner: spinner, | 100 | spinner: spinner, | ||
| 72 | logo: logo, | 101 | logo: logo, | ||
| 102 | + | version: versionLabel, | |||
| 73 | } | 103 | } | ||
| 74 | ls.SetVisible(true) | 104 | ls.SetVisible(true) | ||
| 75 | return ls | 105 | return ls | ||
M internal/ui/component/loading_skeleton_test.go +42 -20
| @@ -17,6 +17,7 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) { | |||||
| 17 | mockContent := mocks.NewMockBoxWidget(t) | 17 | mockContent := mocks.NewMockBoxWidget(t) | ||
| 18 | mockSpinner := mocks.NewMockSpinnerWidget(t) | 18 | mockSpinner := mocks.NewMockSpinnerWidget(t) | ||
| 19 | mockLogo := mocks.NewMockImageWidget(t) | 19 | mockLogo := mocks.NewMockImageWidget(t) | ||
| 20 | + | mockVersion := mocks.NewMockLabelWidget(t) | |||
| 20 | 21 | ||||
| 21 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | 22 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | ||
| 22 | mockContainer.EXPECT().SetHexpand(true).Once() | 23 | mockContainer.EXPECT().SetHexpand(true).Once() | ||
| @@ -34,26 +35,36 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) { | |||||
| 34 | mockContent.EXPECT().SetCanTarget(false).Once() | 35 | mockContent.EXPECT().SetCanTarget(false).Once() | ||
| 35 | mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | 36 | mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | ||
| 36 | 37 | ||||
| 37 | - | mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once() | |||
| 38 | - | mockSpinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 39 | - | mockSpinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 40 | - | mockSpinner.EXPECT().SetCanFocus(false).Once() | |||
| 41 | - | mockSpinner.EXPECT().SetCanTarget(false).Once() | |||
| 42 | - | mockSpinner.EXPECT().SetSizeRequest(32, 32).Once() | |||
| 43 | - | mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 44 | - | ||||
| 45 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | 38 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | ||
| 46 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | 39 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | ||
| 47 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | 40 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | ||
| 48 | mockLogo.EXPECT().SetCanFocus(false).Once() | 41 | mockLogo.EXPECT().SetCanFocus(false).Once() | ||
| 49 | mockLogo.EXPECT().SetCanTarget(false).Once() | 42 | mockLogo.EXPECT().SetCanTarget(false).Once() | ||
| 50 | - | mockLogo.EXPECT().SetSizeRequest(512, 512).Once() | 43 | + | mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() |
| 51 | - | mockLogo.EXPECT().SetPixelSize(512).Once() | 44 | + | mockLogo.EXPECT().SetPixelSize(mock.Anything).Once() |
| 52 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | 45 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | ||
| 53 | mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | 46 | mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | ||
| 54 | 47 | ||||
| 48 | + | mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once() | |||
| 49 | + | mockSpinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 50 | + | mockSpinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 51 | + | mockSpinner.EXPECT().SetCanFocus(false).Once() | |||
| 52 | + | mockSpinner.EXPECT().SetCanTarget(false).Once() | |||
| 53 | + | mockSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() | |||
| 54 | + | mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 55 | + | ||||
| 56 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockVersion).Once() | |||
| 57 | + | mockVersion.EXPECT().SetHalign(mock.Anything).Once() | |||
| 58 | + | mockVersion.EXPECT().SetValign(mock.Anything).Once() | |||
| 59 | + | mockVersion.EXPECT().SetCanFocus(false).Once() | |||
| 60 | + | mockVersion.EXPECT().SetCanTarget(false).Once() | |||
| 61 | + | mockVersion.EXPECT().SetMaxWidthChars(mock.Anything).Once() | |||
| 62 | + | mockVersion.EXPECT().SetEllipsize(mock.Anything).Once() | |||
| 63 | + | mockVersion.EXPECT().AddCssClass("loading-skeleton-version").Once() | |||
| 64 | + | ||||
| 55 | mockContent.EXPECT().Append(mockLogo).Once() | 65 | mockContent.EXPECT().Append(mockLogo).Once() | ||
| 56 | mockContent.EXPECT().Append(mockSpinner).Once() | 66 | mockContent.EXPECT().Append(mockSpinner).Once() | ||
| 67 | + | mockContent.EXPECT().Append(mockVersion).Once() | |||
| 57 | mockContainer.EXPECT().Append(mockContent).Once() | 68 | mockContainer.EXPECT().Append(mockContent).Once() | ||
| 58 | 69 | ||||
| 59 | // NewLoadingSkeleton ends by calling SetVisible(true). | 70 | // NewLoadingSkeleton ends by calling SetVisible(true). | ||
| @@ -70,6 +81,7 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) { | |||||
| 70 | mockContent := mocks.NewMockBoxWidget(t) | 81 | mockContent := mocks.NewMockBoxWidget(t) | ||
| 71 | mockSpinner := mocks.NewMockSpinnerWidget(t) | 82 | mockSpinner := mocks.NewMockSpinnerWidget(t) | ||
| 72 | mockLogo := mocks.NewMockImageWidget(t) | 83 | mockLogo := mocks.NewMockImageWidget(t) | ||
| 84 | + | mockVersion := mocks.NewMockLabelWidget(t) | |||
| 73 | 85 | ||||
| 74 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | 86 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | ||
| 75 | mockContainer.EXPECT().SetHexpand(true).Once() | 87 | mockContainer.EXPECT().SetHexpand(true).Once() | ||
| @@ -87,26 +99,36 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) { | |||||
| 87 | mockContent.EXPECT().SetCanTarget(false).Once() | 99 | mockContent.EXPECT().SetCanTarget(false).Once() | ||
| 88 | mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | 100 | mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | ||
| 89 | 101 | ||||
| 90 | - | mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once() | |||
| 91 | - | mockSpinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 92 | - | mockSpinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 93 | - | mockSpinner.EXPECT().SetCanFocus(false).Once() | |||
| 94 | - | mockSpinner.EXPECT().SetCanTarget(false).Once() | |||
| 95 | - | mockSpinner.EXPECT().SetSizeRequest(32, 32).Once() | |||
| 96 | - | mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 97 | - | ||||
| 98 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | 102 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | ||
| 99 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | 103 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | ||
| 100 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | 104 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | ||
| 101 | mockLogo.EXPECT().SetCanFocus(false).Once() | 105 | mockLogo.EXPECT().SetCanFocus(false).Once() | ||
| 102 | mockLogo.EXPECT().SetCanTarget(false).Once() | 106 | mockLogo.EXPECT().SetCanTarget(false).Once() | ||
| 103 | - | mockLogo.EXPECT().SetSizeRequest(512, 512).Once() | 107 | + | mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() |
| 104 | - | mockLogo.EXPECT().SetPixelSize(512).Once() | 108 | + | mockLogo.EXPECT().SetPixelSize(mock.Anything).Once() |
| 105 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | 109 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | ||
| 106 | mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | 110 | mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | ||
| 107 | 111 | ||||
| 112 | + | mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once() | |||
| 113 | + | mockSpinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 114 | + | mockSpinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 115 | + | mockSpinner.EXPECT().SetCanFocus(false).Once() | |||
| 116 | + | mockSpinner.EXPECT().SetCanTarget(false).Once() | |||
| 117 | + | mockSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() | |||
| 118 | + | mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 119 | + | ||||
| 120 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockVersion).Once() | |||
| 121 | + | mockVersion.EXPECT().SetHalign(mock.Anything).Once() | |||
| 122 | + | mockVersion.EXPECT().SetValign(mock.Anything).Once() | |||
| 123 | + | mockVersion.EXPECT().SetCanFocus(false).Once() | |||
| 124 | + | mockVersion.EXPECT().SetCanTarget(false).Once() | |||
| 125 | + | mockVersion.EXPECT().SetMaxWidthChars(mock.Anything).Once() | |||
| 126 | + | mockVersion.EXPECT().SetEllipsize(mock.Anything).Once() | |||
| 127 | + | mockVersion.EXPECT().AddCssClass("loading-skeleton-version").Once() | |||
| 128 | + | ||||
| 108 | mockContent.EXPECT().Append(mockLogo).Once() | 129 | mockContent.EXPECT().Append(mockLogo).Once() | ||
| 109 | mockContent.EXPECT().Append(mockSpinner).Once() | 130 | mockContent.EXPECT().Append(mockSpinner).Once() | ||
| 131 | + | mockContent.EXPECT().Append(mockVersion).Once() | |||
| 110 | mockContainer.EXPECT().Append(mockContent).Once() | 132 | mockContainer.EXPECT().Append(mockContent).Once() | ||
| 111 | 133 | ||||
| 112 | mockContainer.EXPECT().SetVisible(true).Once() | 134 | mockContainer.EXPECT().SetVisible(true).Once() | ||
M internal/ui/component/omnibox.go +220 -209
| @@ -3,7 +3,7 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "math" | 6 | + | stdurl "net/url" |
| 7 | "strings" | 7 | "strings" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | "time" | 9 | "time" | ||
| @@ -21,15 +21,12 @@ import ( | |||||
| 21 | "github.com/jwijenbergh/puregotk/v4/glib" | 21 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 22 | "github.com/jwijenbergh/puregotk/v4/graphene" | 22 | "github.com/jwijenbergh/puregotk/v4/graphene" | ||
| 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 24 | - | "github.com/jwijenbergh/puregotk/v4/pango" | |||
| 25 | ) | 24 | ) | ||
| 26 | 25 | ||||
| 27 | const ( | 26 | const ( | ||
| 28 | debounceDelayMs = 50 | 27 | debounceDelayMs = 50 | ||
| 29 | endBoxSpacing = 6 | 28 | endBoxSpacing = 6 | ||
| 30 | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | 29 | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | ||
| 31 | - | ghostPositionRetryMs = 16 | |||
| 32 | - | ghostPositionMaxAttempts = 5 | |||
| 33 | minGhostInputLength = 1 | 30 | minGhostInputLength = 1 | ||
| 34 | ) | 31 | ) | ||
| 35 | 32 | ||||
| @@ -76,8 +73,6 @@ type Omnibox struct { | |||||
| 76 | zoomLabel *gtk.Label | 73 | zoomLabel *gtk.Label | ||
| 77 | bangBadge *gtk.Label | 74 | bangBadge *gtk.Label | ||
| 78 | entry *gtk.SearchEntry | 75 | entry *gtk.SearchEntry | ||
| 79 | - | entryOverlay *gtk.Overlay // Overlay for ghost text | |||
| 80 | - | ghostLabel *gtk.Label // Ghost text completion label | |||
| 81 | scrolledWin *gtk.ScrolledWindow | 76 | scrolledWin *gtk.ScrolledWindow | ||
| 82 | listBox *gtk.ListBox | 77 | listBox *gtk.ListBox | ||
| 83 | 78 | ||||
| @@ -123,6 +118,8 @@ type Omnibox struct { | |||||
| 123 | debounceTimer *time.Timer | 118 | debounceTimer *time.Timer | ||
| 124 | debounceMu sync.Mutex | 119 | debounceMu sync.Mutex | ||
| 125 | lastQuery string // Prevent duplicate searches | 120 | lastQuery string // Prevent duplicate searches | ||
| 121 | + | searchToken uint64 | |||
| 122 | + | ghostToken uint64 | |||
| 126 | 123 | ||||
| 127 | // Scaling | 124 | // Scaling | ||
| 128 | uiScale float64 | 125 | uiScale float64 | ||
| @@ -512,30 +509,6 @@ func (o *Omnibox) initEntry() error { | |||||
| 512 | placeholder := defaultOmniboxPlaceholder | 509 | placeholder := defaultOmniboxPlaceholder | ||
| 513 | o.entry.SetPlaceholderText(&placeholder) | 510 | o.entry.SetPlaceholderText(&placeholder) | ||
| 514 | 511 | ||||
| 515 | - | // Create overlay for ghost text | |||
| 516 | - | o.entryOverlay = gtk.NewOverlay() | |||
| 517 | - | if o.entryOverlay == nil { | |||
| 518 | - | return errNilWidget("entryOverlay") | |||
| 519 | - | } | |||
| 520 | - | o.entryOverlay.SetChild(&o.entry.Widget) | |||
| 521 | - | o.entryOverlay.SetHexpand(true) | |||
| 522 | - | ||||
| 523 | - | // Create ghost label for autocomplete suffix | |||
| 524 | - | o.ghostLabel = gtk.NewLabel(nil) | |||
| 525 | - | if o.ghostLabel == nil { | |||
| 526 | - | return errNilWidget("ghostLabel") | |||
| 527 | - | } | |||
| 528 | - | o.ghostLabel.AddCssClass("omnibox-ghost") | |||
| 529 | - | o.ghostLabel.SetHalign(gtk.AlignStartValue) | |||
| 530 | - | o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text | |||
| 531 | - | o.ghostLabel.SetXalign(0) | |||
| 532 | - | o.ghostLabel.SetVisible(false) | |||
| 533 | - | o.ghostLabel.SetCanTarget(false) // Don't intercept clicks | |||
| 534 | - | o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long | |||
| 535 | - | ||||
| 536 | - | o.entryOverlay.AddOverlay(&o.ghostLabel.Widget) | |||
| 537 | - | o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds | |||
| 538 | - | ||||
| 539 | return nil | 512 | return nil | ||
| 540 | } | 513 | } | ||
| 541 | 514 | ||||
| @@ -636,7 +609,7 @@ func (o *Omnibox) assembleWidgets() { | |||||
| 636 | 609 | ||||
| 637 | o.scrolledWin.SetChild(&o.listBox.Widget) | 610 | o.scrolledWin.SetChild(&o.listBox.Widget) | ||
| 638 | o.mainBox.Append(&o.headerBox.Widget) | 611 | o.mainBox.Append(&o.headerBox.Widget) | ||
| 639 | - | o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support | 612 | + | o.mainBox.Append(&o.entry.Widget) |
| 640 | o.mainBox.Append(&o.scrolledWin.Widget) | 613 | o.mainBox.Append(&o.scrolledWin.Widget) | ||
| 641 | o.outerBox.Append(&o.mainBox.Widget) | 614 | o.outerBox.Append(&o.mainBox.Widget) | ||
| 642 | } | 615 | } | ||
| @@ -825,6 +798,10 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 825 | return | 798 | return | ||
| 826 | } | 799 | } | ||
| 827 | 800 | ||||
| 801 | + | if o.isGhostProgrammaticEcho(entryText) { | |||
| 802 | + | return | |||
| 803 | + | } | |||
| 804 | + | ||||
| 828 | // Reset navigation state when user types - space should type, not toggle favorite | 805 | // Reset navigation state when user types - space should type, not toggle favorite | ||
| 829 | o.mu.Lock() | 806 | o.mu.Lock() | ||
| 830 | o.hasNavigated = false | 807 | o.hasNavigated = false | ||
| @@ -850,169 +827,129 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 850 | o.debounceMu.Unlock() | 827 | o.debounceMu.Unlock() | ||
| 851 | } | 828 | } | ||
| 852 | 829 | ||||
| 853 | - | // setGhostText displays a ghost completion suffix in the overlay label. | 830 | + | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { |
| 854 | - | // The ghost text shows what would complete the current input. | 831 | + | o.mu.RLock() |
| 855 | - | // originalInput is the input that was used for the autocomplete query. | 832 | + | hasGhost := o.hasGhostText |
| 856 | - | // If originalInput is empty, the ghost text replaces the entire input (used for row selection). | 833 | + | realInput := o.realInput |
| 834 | + | ghostFullText := o.ghostFullText | |||
| 835 | + | o.mu.RUnlock() | |||
| 836 | + | ||||
| 837 | + | if !hasGhost || ghostFullText == "" || entryText != ghostFullText { | |||
| 838 | + | return false | |||
| 839 | + | } | |||
| 840 | + | ||||
| 841 | + | selectionStart := 0 | |||
| 842 | + | selectionEnd := 0 | |||
| 843 | + | hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd) | |||
| 844 | + | return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection) | |||
| 845 | + | } | |||
| 846 | + | ||||
| 847 | + | func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool { | |||
| 848 | + | if !hasSelection { | |||
| 849 | + | return false | |||
| 850 | + | } | |||
| 851 | + | prefixRunes := utf8.RuneCountInString(realInput) | |||
| 852 | + | fullRunes := utf8.RuneCountInString(ghostFullText) | |||
| 853 | + | if fullRunes <= prefixRunes { | |||
| 854 | + | return false | |||
| 855 | + | } | |||
| 856 | + | return selectionStart == prefixRunes && selectionEnd == fullRunes | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | // setGhostText applies inline completion using entry selection. | |||
| 860 | + | // It writes full text and selects [len(originalInput):end] as the ghost suffix. | |||
| 857 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | 861 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | ||
| 858 | - | if o.ghostLabel == nil || suffix == "" { | 862 | + | if o.entry == nil || suffix == "" { |
| 859 | o.clearGhostText() | 863 | o.clearGhostText() | ||
| 860 | return | 864 | return | ||
| 861 | } | 865 | } | ||
| 862 | 866 | ||||
| 863 | - | // Store ghost state optimistically - will be validated on main thread | 867 | + | displayText := originalInput + suffix |
| 868 | + | if fullText != "" { | |||
| 869 | + | displayText = fullText | |||
| 870 | + | } | |||
| 871 | + | selectionStart := utf8.RuneCountInString(originalInput) | |||
| 872 | + | selectionEnd := utf8.RuneCountInString(displayText) | |||
| 873 | + | ||||
| 864 | o.mu.Lock() | 874 | o.mu.Lock() | ||
| 875 | + | o.realInput = originalInput | |||
| 865 | o.ghostSuffix = suffix | 876 | o.ghostSuffix = suffix | ||
| 866 | o.ghostFullText = fullText | 877 | o.ghostFullText = fullText | ||
| 867 | o.hasGhostText = true | 878 | o.hasGhostText = true | ||
| 879 | + | capturedToken := o.ghostToken | |||
| 868 | o.mu.Unlock() | 880 | o.mu.Unlock() | ||
| 869 | 881 | ||||
| 870 | - | o.scheduleGhostPosition(originalInput, suffix, 0) | |||
| 871 | - | } | |||
| 872 | - | ||||
| 873 | - | func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) { | |||
| 874 | var cb glib.SourceFunc = func(uintptr) bool { | 882 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 875 | - | if o.ghostLabel == nil || o.entry == nil { | 883 | + | if o.entry == nil { |
| 876 | - | return false | |||
| 877 | - | } | |||
| 878 | - | if o.shouldSkipGhostUpdate(originalInput) { | |||
| 879 | return false | 884 | return false | ||
| 880 | } | 885 | } | ||
| 881 | - | if !o.ghostPositionReady(attempt) { | 886 | + | |
| 882 | - | o.retryGhostPosition(originalInput, suffix, attempt+1) | 887 | + | o.mu.RLock() |
| 888 | + | currentInput := o.realInput | |||
| 889 | + | currentToken := o.ghostToken | |||
| 890 | + | o.mu.RUnlock() | |||
| 891 | + | if currentInput != originalInput { | |||
| 883 | return false | 892 | return false | ||
| 884 | } | 893 | } | ||
| 885 | - | 894 | + | if capturedToken != currentToken { | |
| 886 | - | marginStart, ok := o.computeGhostMarginStart(originalInput) | |||
| 887 | - | if !ok { | |||
| 888 | return false | 895 | return false | ||
| 889 | } | 896 | } | ||
| 890 | - | o.applyGhostPosition(suffix, marginStart) | 897 | + | o.mu.Lock() |
| 891 | - | return false | 898 | + | o.isAcceptingGhost = true |
| 892 | - | } | 899 | + | o.mu.Unlock() |
| 893 | - | glib.IdleAdd(&cb, 0) | |||
| 894 | - | } | |||
| 895 | - | ||||
| 896 | - | func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool { | |||
| 897 | - | if originalInput == "" { | |||
| 898 | - | return false | |||
| 899 | - | } | |||
| 900 | - | return o.entry.GetText() != originalInput | |||
| 901 | - | } | |||
| 902 | - | ||||
| 903 | - | func (o *Omnibox) ghostPositionReady(attempt int) bool { | |||
| 904 | - | entryWidth := o.entry.GetAllocatedWidth() | |||
| 905 | - | overlayWidth := 0 | |||
| 906 | - | if o.entryOverlay != nil { | |||
| 907 | - | overlayWidth = o.entryOverlay.GetAllocatedWidth() | |||
| 908 | - | } | |||
| 909 | - | ||||
| 910 | - | if entryWidth > 0 && overlayWidth > 0 { | |||
| 911 | - | return true | |||
| 912 | - | } | |||
| 913 | - | ||||
| 914 | - | // Max attempts reached but widgets still not allocated - give up | |||
| 915 | - | if attempt >= ghostPositionMaxAttempts { | |||
| 916 | - | log := logging.FromContext(o.ctx) | |||
| 917 | - | log.Debug(). | |||
| 918 | - | Int("attempt", attempt). | |||
| 919 | - | Int("entryWidth", entryWidth). | |||
| 920 | - | Int("overlayWidth", overlayWidth). | |||
| 921 | - | Msg("ghost position timeout, clearing ghost text") | |||
| 922 | - | o.clearGhostText() | |||
| 923 | - | return false | |||
| 924 | - | } | |||
| 925 | 900 | ||||
| 926 | - | return false | 901 | + | o.entry.SetText(displayText) |
| 927 | - | } | 902 | + | o.entry.SetPosition(selectionStart) |
| 903 | + | o.entry.SelectRegion(selectionStart, selectionEnd) | |||
| 928 | 904 | ||||
| 929 | - | func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) { | 905 | + | o.mu.Lock() |
| 930 | - | var retryCb glib.SourceFunc = func(uintptr) bool { | 906 | + | o.isAcceptingGhost = false |
| 931 | - | o.scheduleGhostPosition(originalInput, suffix, attempt) | 907 | + | o.mu.Unlock() |
| 932 | return false | 908 | return false | ||
| 933 | } | 909 | } | ||
| 934 | - | glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0) | 910 | + | glib.IdleAdd(&cb, 0) |
| 935 | - | } | |||
| 936 | - | ||||
| 937 | - | func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) { | |||
| 938 | - | pangoCtx := o.entry.GetPangoContext() | |||
| 939 | - | if pangoCtx == nil { | |||
| 940 | - | return 0, false | |||
| 941 | - | } | |||
| 942 | - | ||||
| 943 | - | textLayout := pango.NewLayout(pangoCtx) | |||
| 944 | - | defer textLayout.Unref() // Prevent memory leak | |||
| 945 | - | textLayout.SetText(originalInput, len(originalInput)) | |||
| 946 | - | ||||
| 947 | - | var widthPx, heightPx int | |||
| 948 | - | textLayout.GetPixelSize(&widthPx, &heightPx) | |||
| 949 | - | ||||
| 950 | - | marginStart := widthPx | |||
| 951 | - | delegate := o.entry.GetDelegate() | |||
| 952 | - | if delegate != nil && o.entryOverlay != nil { | |||
| 953 | - | textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer()) | |||
| 954 | - | if textWidget != nil { | |||
| 955 | - | var textBounds graphene.Rect | |||
| 956 | - | if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) { | |||
| 957 | - | textStart := float64(textBounds.GetX()) | |||
| 958 | - | cursorPos := uint(utf8.RuneCountInString(originalInput)) | |||
| 959 | - | var strongRect, weakRect graphene.Rect | |||
| 960 | - | textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect) | |||
| 961 | - | cursorX := float64(textBounds.GetX() + strongRect.GetX()) | |||
| 962 | - | marginStart = int(math.Round(cursorX)) | |||
| 963 | - | if cursorX < textStart { | |||
| 964 | - | marginStart = int(math.Round(textStart)) | |||
| 965 | - | } | |||
| 966 | - | } | |||
| 967 | - | } | |||
| 968 | - | } | |||
| 969 | - | ||||
| 970 | - | if marginStart < 0 { | |||
| 971 | - | marginStart = 0 | |||
| 972 | - | } | |||
| 973 | - | if o.entryOverlay != nil { | |||
| 974 | - | var entryBounds graphene.Rect | |||
| 975 | - | if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) { | |||
| 976 | - | minStart := int(math.Round(float64(entryBounds.GetX()))) | |||
| 977 | - | if marginStart < minStart { | |||
| 978 | - | marginStart = minStart | |||
| 979 | - | } | |||
| 980 | - | } | |||
| 981 | - | } | |||
| 982 | - | ||||
| 983 | - | return marginStart, true | |||
| 984 | - | } | |||
| 985 | - | ||||
| 986 | - | func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) { | |||
| 987 | - | o.ghostLabel.SetText(suffix) | |||
| 988 | - | o.ghostLabel.SetMarginStart(marginStart) | |||
| 989 | - | o.ghostLabel.SetVisible(true) | |||
| 990 | - | emptyPlaceholder := "" | |||
| 991 | - | o.entry.SetPlaceholderText(&emptyPlaceholder) | |||
| 992 | } | 911 | } | ||
| 993 | 912 | ||||
| 994 | // clearGhostText hides the ghost completion text. | 913 | // clearGhostText hides the ghost completion text. | ||
| 995 | func (o *Omnibox) clearGhostText() { | 914 | func (o *Omnibox) clearGhostText() { | ||
| 996 | o.mu.Lock() | 915 | o.mu.Lock() | ||
| 916 | + | realInput := o.realInput | |||
| 917 | + | ghostFullText := o.ghostFullText | |||
| 918 | + | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |||
| 919 | + | o.ghostToken++ | |||
| 920 | + | capturedToken := o.ghostToken | |||
| 997 | o.ghostSuffix = "" | 921 | o.ghostSuffix = "" | ||
| 998 | o.ghostFullText = "" | 922 | o.ghostFullText = "" | ||
| 999 | o.hasGhostText = false | 923 | o.hasGhostText = false | ||
| 1000 | o.mu.Unlock() | 924 | o.mu.Unlock() | ||
| 1001 | 925 | ||||
| 1002 | - | if o.ghostLabel == nil { | 926 | + | if !hadGhost || o.entry == nil { |
| 1003 | return | 927 | return | ||
| 1004 | } | 928 | } | ||
| 1005 | 929 | ||||
| 1006 | var cb glib.SourceFunc = func(uintptr) bool { | 930 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 1007 | - | if o.ghostLabel != nil { | 931 | + | if o.entry == nil { |
| 1008 | - | o.ghostLabel.SetVisible(false) | 932 | + | return false |
| 1009 | - | o.ghostLabel.SetText("") | |||
| 1010 | } | 933 | } | ||
| 1011 | - | // Restore placeholder text | 934 | + | o.mu.RLock() |
| 1012 | - | if o.entry != nil { | 935 | + | currentToken := o.ghostToken |
| 1013 | - | placeholder := defaultOmniboxPlaceholder | 936 | + | o.mu.RUnlock() |
| 1014 | - | o.entry.SetPlaceholderText(&placeholder) | 937 | + | if capturedToken != currentToken { |
| 938 | + | return false | |||
| 1015 | } | 939 | } | ||
| 940 | + | o.mu.Lock() | |||
| 941 | + | o.isAcceptingGhost = true | |||
| 942 | + | o.mu.Unlock() | |||
| 943 | + | // Only revert text when the entry still shows the previous ghost completion. | |||
| 944 | + | // If the user typed since scheduling this idle callback, never overwrite it. | |||
| 945 | + | if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput { | |||
| 946 | + | o.entry.SetText(realInput) | |||
| 947 | + | } | |||
| 948 | + | o.entry.SelectRegion(-1, -1) | |||
| 949 | + | o.entry.SetPosition(-1) | |||
| 950 | + | o.mu.Lock() | |||
| 951 | + | o.isAcceptingGhost = false | |||
| 952 | + | o.mu.Unlock() | |||
| 1016 | return false | 953 | return false | ||
| 1017 | } | 954 | } | ||
| 1018 | glib.IdleAdd(&cb, 0) | 955 | glib.IdleAdd(&cb, 0) | ||
| @@ -1021,54 +958,42 @@ func (o *Omnibox) clearGhostText() { | |||||
| 1021 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | ||
| 1022 | // This prevents stale background results from wiping newer ghost text. | 959 | // This prevents stale background results from wiping newer ghost text. | ||
| 1023 | func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | 960 | func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | ||
| 1024 | - | if o.entry == nil { | 961 | + | o.mu.RLock() |
| 962 | + | currentInput := o.realInput | |||
| 963 | + | o.mu.RUnlock() | |||
| 964 | + | if currentInput != expectedInput { | |||
| 1025 | return | 965 | return | ||
| 1026 | } | 966 | } | ||
| 1027 | - | 967 | + | o.clearGhostText() | |
| 1028 | - | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 1029 | - | if o.entry == nil { | |||
| 1030 | - | return false | |||
| 1031 | - | } | |||
| 1032 | - | if o.entry.GetText() != expectedInput { | |||
| 1033 | - | return false | |||
| 1034 | - | } | |||
| 1035 | - | o.mu.Lock() | |||
| 1036 | - | o.ghostSuffix = "" | |||
| 1037 | - | o.ghostFullText = "" | |||
| 1038 | - | o.hasGhostText = false | |||
| 1039 | - | o.mu.Unlock() | |||
| 1040 | - | if o.ghostLabel != nil { | |||
| 1041 | - | o.ghostLabel.SetVisible(false) | |||
| 1042 | - | o.ghostLabel.SetText("") | |||
| 1043 | - | } | |||
| 1044 | - | placeholder := defaultOmniboxPlaceholder | |||
| 1045 | - | o.entry.SetPlaceholderText(&placeholder) | |||
| 1046 | - | return false | |||
| 1047 | - | } | |||
| 1048 | - | glib.IdleAdd(&cb, 0) | |||
| 1049 | } | 968 | } | ||
| 1050 | 969 | ||||
| 1051 | // acceptGhostCompletion accepts the ghost text and fills the input. | 970 | // acceptGhostCompletion accepts the ghost text and fills the input. | ||
| 1052 | func (o *Omnibox) acceptGhostCompletion() { | 971 | func (o *Omnibox) acceptGhostCompletion() { | ||
| 1053 | o.mu.Lock() | 972 | o.mu.Lock() | ||
| 1054 | - | if !o.hasGhostText || o.ghostFullText == "" { | 973 | + | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) |
| 974 | + | if !accepted { | |||
| 1055 | o.mu.Unlock() | 975 | o.mu.Unlock() | ||
| 1056 | return | 976 | return | ||
| 1057 | } | 977 | } | ||
| 1058 | - | fullText := o.ghostFullText | |||
| 1059 | o.isAcceptingGhost = true | 978 | o.isAcceptingGhost = true | ||
| 1060 | o.realInput = fullText | 979 | o.realInput = fullText | ||
| 980 | + | o.selectedIndex = -1 | |||
| 981 | + | o.hasNavigated = false | |||
| 982 | + | o.ghostToken++ | |||
| 1061 | o.ghostSuffix = "" | 983 | o.ghostSuffix = "" | ||
| 1062 | o.ghostFullText = "" | 984 | o.ghostFullText = "" | ||
| 1063 | o.hasGhostText = false | 985 | o.hasGhostText = false | ||
| 1064 | o.mu.Unlock() | 986 | o.mu.Unlock() | ||
| 1065 | 987 | ||||
| 1066 | - | // Update entry text and clear ghost label | 988 | + | if o.entry != nil { |
| 1067 | - | o.entry.SetText(fullText) | 989 | + | if o.entry.GetText() != fullText { |
| 1068 | - | o.entry.SetPosition(-1) // Move cursor to end | 990 | + | o.entry.SetText(fullText) |
| 1069 | - | 991 | + | } | |
| 1070 | - | if o.ghostLabel != nil { | 992 | + | o.entry.SelectRegion(-1, -1) |
| 1071 | - | o.ghostLabel.SetVisible(false) | 993 | + | o.entry.SetPosition(-1) |
| 994 | + | } | |||
| 995 | + | if o.listBox != nil { | |||
| 996 | + | o.listBox.SelectRow(nil) | |||
| 1072 | } | 997 | } | ||
| 1073 | 998 | ||||
| 1074 | o.mu.Lock() | 999 | o.mu.Lock() | ||
| @@ -1076,6 +1001,13 @@ func (o *Omnibox) acceptGhostCompletion() { | |||||
| 1076 | o.mu.Unlock() | 1001 | o.mu.Unlock() | ||
| 1077 | } | 1002 | } | ||
| 1078 | 1003 | ||||
| 1004 | + | func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) { | |||
| 1005 | + | if !hasGhost || ghostFullText == "" { | |||
| 1006 | + | return "", false | |||
| 1007 | + | } | |||
| 1008 | + | return ghostFullText, true | |||
| 1009 | + | } | |||
| 1010 | + | ||||
| 1079 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | 1011 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | ||
| 1080 | // When a row is selected via arrow keys, show the URL as ghost text even if | 1012 | // When a row is selected via arrow keys, show the URL as ghost text even if | ||
| 1081 | // the user's input isn't a prefix - this shows what Tab would fill in. | 1013 | // the user's input isn't a prefix - this shows what Tab would fill in. | ||
| @@ -1179,19 +1111,83 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges | |||||
| 1179 | } | 1111 | } | ||
| 1180 | } | 1112 | } | ||
| 1181 | 1113 | ||||
| 1114 | + | o.mu.Lock() | |||
| 1115 | + | o.ghostToken++ | |||
| 1116 | + | ghostToken := o.ghostToken | |||
| 1117 | + | searchToken := o.searchToken | |||
| 1118 | + | o.mu.Unlock() | |||
| 1119 | + | ||||
| 1182 | go func() { | 1120 | go func() { | ||
| 1183 | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | 1121 | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | ||
| 1184 | VisibleURLs: visibleURLs, | 1122 | VisibleURLs: visibleURLs, | ||
| 1185 | AllowBangs: false, | 1123 | AllowBangs: false, | ||
| 1186 | }) | 1124 | }) | ||
| 1125 | + | if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) { | |||
| 1126 | + | return | |||
| 1127 | + | } | |||
| 1187 | if suggestion == nil { | 1128 | if suggestion == nil { | ||
| 1188 | o.clearGhostTextIfInput(entryText) | 1129 | o.clearGhostTextIfInput(entryText) | ||
| 1189 | return | 1130 | return | ||
| 1190 | } | 1131 | } | ||
| 1191 | - | o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText) | 1132 | + | fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix) |
| 1133 | + | if fullText == "" || suffix == "" { | |||
| 1134 | + | o.clearGhostTextIfInput(entryText) | |||
| 1135 | + | return | |||
| 1136 | + | } | |||
| 1137 | + | o.setGhostText(entryText, suffix, fullText) | |||
| 1192 | }() | 1138 | }() | ||
| 1193 | } | 1139 | } | ||
| 1194 | 1140 | ||||
| 1141 | + | // normalizeGhostSuggestion trims noisy URL completions to a domain completion when input | |||
| 1142 | + | // appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL). | |||
| 1143 | + | func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) { | |||
| 1144 | + | if queryText == "" || fullText == "" { | |||
| 1145 | + | return "", "" | |||
| 1146 | + | } | |||
| 1147 | + | ||||
| 1148 | + | // If input already contains path/query-ish delimiters, keep original completion behavior. | |||
| 1149 | + | if strings.ContainsAny(queryText, "/?#=& ") { | |||
| 1150 | + | return fullText, fallbackSuffix | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1153 | + | hostOnly := extractHostForCompletion(fullText) | |||
| 1154 | + | if hostOnly != "" { | |||
| 1155 | + | for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} { | |||
| 1156 | + | if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok { | |||
| 1157 | + | return candidate, completionSuffix | |||
| 1158 | + | } | |||
| 1159 | + | } | |||
| 1160 | + | } | |||
| 1161 | + | ||||
| 1162 | + | return fullText, fallbackSuffix | |||
| 1163 | + | } | |||
| 1164 | + | ||||
| 1165 | + | func extractHostForCompletion(raw string) string { | |||
| 1166 | + | if raw == "" { | |||
| 1167 | + | return "" | |||
| 1168 | + | } | |||
| 1169 | + | if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" { | |||
| 1170 | + | return strings.ToLower(parsed.Hostname()) | |||
| 1171 | + | } | |||
| 1172 | + | trimmed := autocomplete.StripProtocol(raw) | |||
| 1173 | + | trimmed = strings.ToLower(trimmed) | |||
| 1174 | + | for _, sep := range []string{"/", "?", "#"} { | |||
| 1175 | + | if idx := strings.Index(trimmed, sep); idx >= 0 { | |||
| 1176 | + | trimmed = trimmed[:idx] | |||
| 1177 | + | } | |||
| 1178 | + | } | |||
| 1179 | + | return strings.TrimSpace(trimmed) | |||
| 1180 | + | } | |||
| 1181 | + | ||||
| 1182 | + | func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool { | |||
| 1183 | + | o.mu.RLock() | |||
| 1184 | + | defer o.mu.RUnlock() | |||
| 1185 | + | return o.visible && | |||
| 1186 | + | o.searchToken == searchToken && | |||
| 1187 | + | o.ghostToken == ghostToken && | |||
| 1188 | + | o.realInput == query | |||
| 1189 | + | } | |||
| 1190 | + | ||||
| 1195 | // performSearch executes the search based on current view mode and query. | 1191 | // performSearch executes the search based on current view mode and query. | ||
| 1196 | func (o *Omnibox) performSearch() { | 1192 | func (o *Omnibox) performSearch() { | ||
| 1197 | o.mu.RLock() | 1193 | o.mu.RLock() | ||
| @@ -1215,6 +1211,11 @@ func (o *Omnibox) performSearch() { | |||||
| 1215 | o.lastQuery = query | 1211 | o.lastQuery = query | ||
| 1216 | o.debounceMu.Unlock() | 1212 | o.debounceMu.Unlock() | ||
| 1217 | 1213 | ||||
| 1214 | + | o.mu.Lock() | |||
| 1215 | + | o.searchToken++ | |||
| 1216 | + | token := o.searchToken | |||
| 1217 | + | o.mu.Unlock() | |||
| 1218 | + | ||||
| 1218 | if strings.HasPrefix(query, "!") { | 1219 | if strings.HasPrefix(query, "!") { | ||
| 1219 | o.updateBangDetection(query) | 1220 | o.updateBangDetection(query) | ||
| 1220 | o.loadBangSuggestions(query) | 1221 | o.loadBangSuggestions(query) | ||
| @@ -1224,23 +1225,23 @@ func (o *Omnibox) performSearch() { | |||||
| 1224 | o.clearBangState() | 1225 | o.clearBangState() | ||
| 1225 | 1226 | ||||
| 1226 | if mode == ViewModeFavorites { | 1227 | if mode == ViewModeFavorites { | ||
| 1227 | - | o.loadFavorites(query) | 1228 | + | o.loadFavorites(query, token) |
| 1228 | return | 1229 | return | ||
| 1229 | } | 1230 | } | ||
| 1230 | 1231 | ||||
| 1231 | // History search | 1232 | // History search | ||
| 1232 | if query == "" { | 1233 | if query == "" { | ||
| 1233 | - | o.loadInitialHistory() | 1234 | + | o.loadInitialHistory(token) |
| 1234 | return | 1235 | return | ||
| 1235 | } | 1236 | } | ||
| 1236 | 1237 | ||||
| 1237 | // Perform fuzzy history search in background | 1238 | // Perform fuzzy history search in background | ||
| 1238 | - | o.searchHistory(query, o.effectiveMaxRows()) | 1239 | + | o.searchHistory(query, o.effectiveMaxRows(), token) |
| 1239 | } | 1240 | } | ||
| 1240 | 1241 | ||||
| 1241 | // searchHistory runs a fuzzy history search in a background goroutine. | 1242 | // searchHistory runs a fuzzy history search in a background goroutine. | ||
| 1242 | // query is the search text; limit caps the number of results. | 1243 | // query is the search text; limit caps the number of results. | ||
| 1243 | - | func (o *Omnibox) searchHistory(query string, limit int) { | 1244 | + | func (o *Omnibox) searchHistory(query string, limit int, token uint64) { |
| 1244 | go func() { | 1245 | go func() { | ||
| 1245 | ctx := o.ctx | 1246 | ctx := o.ctx | ||
| 1246 | log := logging.FromContext(ctx) | 1247 | log := logging.FromContext(ctx) | ||
| @@ -1291,13 +1292,13 @@ func (o *Omnibox) searchHistory(query string, limit int) { | |||||
| 1291 | }) | 1292 | }) | ||
| 1292 | } | 1293 | } | ||
| 1293 | 1294 | ||||
| 1294 | - | // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input | 1295 | + | // Marshal back to GTK main thread, passing query for stale-result guarding. |
| 1295 | - | o.idleAddUpdateSuggestions(suggestions, query) | 1296 | + | o.idleAddUpdateSuggestions(suggestions, query, token) |
| 1296 | }() | 1297 | }() | ||
| 1297 | } | 1298 | } | ||
| 1298 | 1299 | ||||
| 1299 | // loadInitialHistory loads history based on InitialBehavior config. | 1300 | // loadInitialHistory loads history based on InitialBehavior config. | ||
| 1300 | - | func (o *Omnibox) loadInitialHistory() { | 1301 | + | func (o *Omnibox) loadInitialHistory(token uint64) { |
| 1301 | if o.historyUC == nil { | 1302 | if o.historyUC == nil { | ||
| 1302 | return | 1303 | return | ||
| 1303 | } | 1304 | } | ||
| @@ -1312,7 +1313,7 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 1312 | 1313 | ||||
| 1313 | switch o.initialBehavior { | 1314 | switch o.initialBehavior { | ||
| 1314 | case "none": | 1315 | case "none": | ||
| 1315 | - | o.idleAddUpdateSuggestions(nil, "") | 1316 | + | o.idleAddUpdateSuggestions(nil, "", token) |
| 1316 | return | 1317 | return | ||
| 1317 | 1318 | ||||
| 1318 | case "most_visited", "recent", "": | 1319 | case "most_visited", "recent", "": | ||
| @@ -1357,12 +1358,12 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 1357 | } | 1358 | } | ||
| 1358 | } | 1359 | } | ||
| 1359 | 1360 | ||||
| 1360 | - | o.idleAddUpdateSuggestions(suggestions, "") | 1361 | + | o.idleAddUpdateSuggestions(suggestions, "", token) |
| 1361 | }() | 1362 | }() | ||
| 1362 | } | 1363 | } | ||
| 1363 | 1364 | ||||
| 1364 | // loadFavorites loads favorites, optionally filtered by query. | 1365 | // loadFavorites loads favorites, optionally filtered by query. | ||
| 1365 | - | func (o *Omnibox) loadFavorites(query string) { | 1366 | + | func (o *Omnibox) loadFavorites(query string, token uint64) { |
| 1366 | if o.favoritesUC == nil { | 1367 | if o.favoritesUC == nil { | ||
| 1367 | return | 1368 | return | ||
| 1368 | } | 1369 | } | ||
| @@ -1395,7 +1396,7 @@ func (o *Omnibox) loadFavorites(query string) { | |||||
| 1395 | }) | 1396 | }) | ||
| 1396 | } | 1397 | } | ||
| 1397 | 1398 | ||||
| 1398 | - | o.idleAddUpdateFavorites(favorites, query) | 1399 | + | o.idleAddUpdateFavorites(favorites, query, token) |
| 1399 | }() | 1400 | }() | ||
| 1400 | } | 1401 | } | ||
| 1401 | 1402 | ||||
| @@ -1976,6 +1977,7 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1976 | log.Debug().Msg("remove favorite: invalid ID") | 1977 | log.Debug().Msg("remove favorite: invalid ID") | ||
| 1977 | return | 1978 | return | ||
| 1978 | } | 1979 | } | ||
| 1980 | + | entryText := o.entry.GetText() | |||
| 1979 | 1981 | ||||
| 1980 | go func() { | 1982 | go func() { | ||
| 1981 | ctx := o.ctx | 1983 | ctx := o.ctx | ||
| @@ -2000,7 +2002,10 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 2000 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | 2002 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | ||
| 2001 | 2003 | ||||
| 2002 | // Refresh favorites list and show toast | 2004 | // Refresh favorites list and show toast | ||
| 2003 | - | o.loadFavorites(o.entry.GetText()) | 2005 | + | o.mu.RLock() |
| 2006 | + | currentToken := o.searchToken | |||
| 2007 | + | o.mu.RUnlock() | |||
| 2008 | + | o.loadFavorites(entryText, currentToken) | |||
| 2004 | if o.onToast != nil { | 2009 | if o.onToast != nil { | ||
| 2005 | cb := glib.SourceFunc(func(_ uintptr) bool { | 2010 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 2006 | o.onToast(ctx, "Favorite removed", ToastSuccess) | 2011 | o.onToast(ctx, "Favorite removed", ToastSuccess) | ||
| @@ -2139,13 +2144,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2139 | o.isAcceptingGhost = false | 2144 | o.isAcceptingGhost = false | ||
| 2140 | o.mu.Unlock() | 2145 | o.mu.Unlock() | ||
| 2141 | 2146 | ||||
| 2142 | - | // Clear any stale ghost text display | |||
| 2143 | - | if o.ghostLabel != nil { | |||
| 2144 | - | o.ghostLabel.SetVisible(false) | |||
| 2145 | - | } | |||
| 2146 | - | ||||
| 2147 | // Set initial query | 2147 | // Set initial query | ||
| 2148 | o.entry.SetText(query) | 2148 | o.entry.SetText(query) | ||
| 2149 | + | o.entry.SelectRegion(-1, -1) | |||
| 2149 | 2150 | ||||
| 2150 | // Determine if we expect content initially | 2151 | // Determine if we expect content initially | ||
| 2151 | // No content expected if: no query AND initialBehavior is "none" | 2152 | // No content expected if: no query AND initialBehavior is "none" | ||
| @@ -2268,9 +2269,11 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) { | |||||
| 2268 | } | 2269 | } | ||
| 2269 | 2270 | ||||
| 2270 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | 2271 | // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread. | ||
| 2271 | - | // The query parameter is passed through to ensure ghost text uses the correct input. | 2272 | + | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) { |
| 2272 | - | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) { | |||
| 2273 | var cb glib.SourceFunc = func(data uintptr) bool { | 2273 | var cb glib.SourceFunc = func(data uintptr) bool { | ||
| 2274 | + | if !o.isSearchTokenCurrent(token) { | |||
| 2275 | + | return false | |||
| 2276 | + | } | |||
| 2274 | o.updateSuggestions(suggestions, query) | 2277 | o.updateSuggestions(suggestions, query) | ||
| 2275 | return false // One-shot callback | 2278 | return false // One-shot callback | ||
| 2276 | } | 2279 | } | ||
| @@ -2293,15 +2296,23 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} { | |||||
| 2293 | } | 2296 | } | ||
| 2294 | 2297 | ||||
| 2295 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | 2298 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | ||
| 2296 | - | // The query parameter is passed through to ensure ghost text uses the correct input. | 2299 | + | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) { |
| 2297 | - | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) { | |||
| 2298 | var cb glib.SourceFunc = func(data uintptr) bool { | 2300 | var cb glib.SourceFunc = func(data uintptr) bool { | ||
| 2301 | + | if !o.isSearchTokenCurrent(token) { | |||
| 2302 | + | return false | |||
| 2303 | + | } | |||
| 2299 | o.updateFavorites(favorites, query) | 2304 | o.updateFavorites(favorites, query) | ||
| 2300 | return false // One-shot callback, return false to remove source | 2305 | return false // One-shot callback, return false to remove source | ||
| 2301 | } | 2306 | } | ||
| 2302 | glib.IdleAdd(&cb, 0) | 2307 | glib.IdleAdd(&cb, 0) | ||
| 2303 | } | 2308 | } | ||
| 2304 | 2309 | ||||
| 2310 | + | func (o *Omnibox) isSearchTokenCurrent(token uint64) bool { | |||
| 2311 | + | o.mu.RLock() | |||
| 2312 | + | defer o.mu.RUnlock() | |||
| 2313 | + | return o.visible && o.searchToken == token | |||
| 2314 | + | } | |||
| 2315 | + | ||||
| 2305 | // Widget returns the omnibox widget for embedding in an overlay. | 2316 | // Widget returns the omnibox widget for embedding in an overlay. | ||
| 2306 | func (o *Omnibox) Widget() *gtk.Widget { | 2317 | func (o *Omnibox) Widget() *gtk.Widget { | ||
| 2307 | if o.outerBox == nil { | 2318 | if o.outerBox == nil { | ||
A internal/ui/component/omnibox_async_guard_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestOmnibox_IsSearchTokenCurrent(t *testing.T) { | |||
| 6 | + | o := &Omnibox{} | |||
| 7 | + | o.visible = true | |||
| 8 | + | o.searchToken = 3 | |||
| 9 | + | ||||
| 10 | + | if !o.isSearchTokenCurrent(3) { | |||
| 11 | + | t.Fatalf("expected token to be current") | |||
| 12 | + | } | |||
| 13 | + | if o.isSearchTokenCurrent(2) { | |||
| 14 | + | t.Fatalf("expected stale token to be rejected") | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | o.visible = false | |||
| 18 | + | if o.isSearchTokenCurrent(3) { | |||
| 19 | + | t.Fatalf("expected hidden omnibox to reject token") | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestOmnibox_IsGhostTokenCurrent(t *testing.T) { | |||
| 24 | + | o := &Omnibox{} | |||
| 25 | + | o.visible = true | |||
| 26 | + | o.searchToken = 8 | |||
| 27 | + | o.ghostToken = 5 | |||
| 28 | + | o.realInput = "git" | |||
| 29 | + | ||||
| 30 | + | if !o.isGhostTokenCurrent(8, 5, "git") { | |||
| 31 | + | t.Fatalf("expected ghost token to be current") | |||
| 32 | + | } | |||
| 33 | + | if o.isGhostTokenCurrent(7, 5, "git") { | |||
| 34 | + | t.Fatalf("expected stale search token to be rejected") | |||
| 35 | + | } | |||
| 36 | + | if o.isGhostTokenCurrent(8, 4, "git") { | |||
| 37 | + | t.Fatalf("expected stale ghost token to be rejected") | |||
| 38 | + | } | |||
| 39 | + | if o.isGhostTokenCurrent(8, 5, "github") { | |||
| 40 | + | t.Fatalf("expected query mismatch to be rejected") | |||
| 41 | + | } | |||
| 42 | + | } | |||
A internal/ui/component/omnibox_ghost_normalize_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestNormalizeGhostSuggestion_PrefersHostOnlyForRedirectURL(t *testing.T) { | |||
| 6 | + | full, suffix := normalizeGhostSuggestion( | |||
| 7 | + | "google", | |||
| 8 | + | "google.com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc", | |||
| 9 | + | ".com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc", | |||
| 10 | + | ) | |||
| 11 | + | if full != "google.com" { | |||
| 12 | + | t.Fatalf("expected host-only completion, got %q", full) | |||
| 13 | + | } | |||
| 14 | + | if suffix != ".com" { | |||
| 15 | + | t.Fatalf("expected .com suffix, got %q", suffix) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestNormalizeGhostSuggestion_KeepsFallbackWhenInputLooksLikePath(t *testing.T) { | |||
| 20 | + | fallback := "/url?q=https://x" | |||
| 21 | + | full, suffix := normalizeGhostSuggestion("google.com/u", "google.com/url?q=https://x", fallback) | |||
| 22 | + | if full != "google.com/url?q=https://x" { | |||
| 23 | + | t.Fatalf("expected full URL fallback, got %q", full) | |||
| 24 | + | } | |||
| 25 | + | if suffix != fallback { | |||
| 26 | + | t.Fatalf("expected fallback suffix, got %q", suffix) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestExtractHostForCompletion(t *testing.T) { | |||
| 31 | + | got := extractHostForCompletion("https://WWW.Google.com/url?q=abc") | |||
| 32 | + | if got != "www.google.com" { | |||
| 33 | + | t.Fatalf("expected normalized host, got %q", got) | |||
| 34 | + | } | |||
| 35 | + | } | |||
A internal/ui/component/omnibox_ghost_state_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestInlineGhostSelection(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | realInput string | |||
| 9 | + | ghostFullText string | |||
| 10 | + | selectionStart int | |||
| 11 | + | selectionEnd int | |||
| 12 | + | hasSelection bool | |||
| 13 | + | want bool | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "selected suffix matches typed prefix", | |||
| 17 | + | realInput: "goo", | |||
| 18 | + | ghostFullText: "google.com", | |||
| 19 | + | selectionStart: 3, | |||
| 20 | + | selectionEnd: 10, | |||
| 21 | + | hasSelection: true, | |||
| 22 | + | want: true, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | name: "no selection means no ghost suffix", | |||
| 26 | + | realInput: "goo", | |||
| 27 | + | ghostFullText: "google.com", | |||
| 28 | + | selectionStart: 10, | |||
| 29 | + | selectionEnd: 10, | |||
| 30 | + | hasSelection: false, | |||
| 31 | + | want: false, | |||
| 32 | + | }, | |||
| 33 | + | { | |||
| 34 | + | name: "collapsed selection should not be treated as ghost", | |||
| 35 | + | realInput: "goo", | |||
| 36 | + | ghostFullText: "google.com", | |||
| 37 | + | selectionStart: 10, | |||
| 38 | + | selectionEnd: 10, | |||
| 39 | + | hasSelection: true, | |||
| 40 | + | want: false, | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "wrong selection range should not be treated as ghost", | |||
| 44 | + | realInput: "goo", | |||
| 45 | + | ghostFullText: "google.com", | |||
| 46 | + | selectionStart: 1, | |||
| 47 | + | selectionEnd: 10, | |||
| 48 | + | hasSelection: true, | |||
| 49 | + | want: false, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | got := isInlineGhostSelection( | |||
| 56 | + | tt.realInput, | |||
| 57 | + | tt.ghostFullText, | |||
| 58 | + | tt.selectionStart, | |||
| 59 | + | tt.selectionEnd, | |||
| 60 | + | tt.hasSelection, | |||
| 61 | + | ) | |||
| 62 | + | if got != tt.want { | |||
| 63 | + | t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want) | |||
| 64 | + | } | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestAcceptedGhostInput(t *testing.T) { | |||
| 70 | + | tests := []struct { | |||
| 71 | + | name string | |||
| 72 | + | hasGhost bool | |||
| 73 | + | ghostFullText string | |||
| 74 | + | wantText string | |||
| 75 | + | wantAccepted bool | |||
| 76 | + | }{ | |||
| 77 | + | { | |||
| 78 | + | name: "accepts explicit ghost", | |||
| 79 | + | hasGhost: true, | |||
| 80 | + | ghostFullText: "google.com", | |||
| 81 | + | wantText: "google.com", | |||
| 82 | + | wantAccepted: true, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "rejects without ghost", | |||
| 86 | + | hasGhost: false, | |||
| 87 | + | ghostFullText: "google.com", | |||
| 88 | + | wantText: "", | |||
| 89 | + | wantAccepted: false, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "rejects empty ghost text", | |||
| 93 | + | hasGhost: true, | |||
| 94 | + | ghostFullText: "", | |||
| 95 | + | wantText: "", | |||
| 96 | + | wantAccepted: false, | |||
| 97 | + | }, | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | for _, tt := range tests { | |||
| 101 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 102 | + | gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText) | |||
| 103 | + | if gotText != tt.wantText { | |||
| 104 | + | t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText) | |||
| 105 | + | } | |||
| 106 | + | if gotAccepted != tt.wantAccepted { | |||
| 107 | + | t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
M internal/ui/component/pane_view.go +25 -4
| @@ -29,9 +29,10 @@ type PaneView struct { | |||||
| 29 | paneID entity.PaneID | 29 | paneID entity.PaneID | ||
| 30 | isActive bool | 30 | isActive bool | ||
| 31 | 31 | ||||
| 32 | - | onFocusIn func(paneID entity.PaneID) | 32 | + | onFocusIn func(paneID entity.PaneID) |
| 33 | - | onFocusOut func(paneID entity.PaneID) | 33 | + | onFocusOut func(paneID entity.PaneID) |
| 34 | - | onHover func(paneID entity.PaneID) | 34 | + | onHover func(paneID entity.PaneID) |
| 35 | + | onMouseMotion func() | |||
| 35 | 36 | ||||
| 36 | hoverHandler *input.HoverHandler | 37 | hoverHandler *input.HoverHandler | ||
| 37 | 38 | ||||
| @@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) { | |||||
| 218 | pv.onHover = fn | 219 | pv.onHover = fn | ||
| 219 | } | 220 | } | ||
| 220 | 221 | ||||
| 222 | + | // SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane. | |||
| 223 | + | // Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement. | |||
| 224 | + | func (pv *PaneView) SetOnMouseMotion(fn func()) { | |||
| 225 | + | pv.mu.Lock() | |||
| 226 | + | defer pv.mu.Unlock() | |||
| 227 | + | ||||
| 228 | + | pv.onMouseMotion = fn | |||
| 229 | + | } | |||
| 230 | + | ||||
| 221 | // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior. | 231 | // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior. | ||
| 222 | func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | 232 | func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | ||
| 223 | pv.mu.Lock() | 233 | pv.mu.Lock() | ||
| @@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||||
| 226 | // Create hover handler | 236 | // Create hover handler | ||
| 227 | pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID) | 237 | pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID) | ||
| 228 | 238 | ||||
| 229 | - | // Wire up callback | 239 | + | // Wire up hover enter callback |
| 230 | pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) { | 240 | pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) { | ||
| 231 | pv.mu.RLock() | 241 | pv.mu.RLock() | ||
| 232 | callback := pv.onHover | 242 | callback := pv.onHover | ||
| @@ -237,6 +247,17 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||||
| 237 | } | 247 | } | ||
| 238 | }) | 248 | }) | ||
| 239 | 249 | ||||
| 250 | + | // Wire up mouse motion callback (intentional movement detection) | |||
| 251 | + | pv.hoverHandler.SetOnMotion(func() { | |||
| 252 | + | pv.mu.RLock() | |||
| 253 | + | callback := pv.onMouseMotion | |||
| 254 | + | pv.mu.RUnlock() | |||
| 255 | + | ||||
| 256 | + | if callback != nil { | |||
| 257 | + | callback() | |||
| 258 | + | } | |||
| 259 | + | }) | |||
| 260 | + | ||||
| 240 | // Attach to overlay widget | 261 | // Attach to overlay widget | ||
| 241 | gtkWidget := pv.overlay.GtkWidget() | 262 | gtkWidget := pv.overlay.GtkWidget() | ||
| 242 | if gtkWidget != nil { | 263 | if gtkWidget != nil { | ||
M internal/ui/component/pane_view_internal_test.go +21 -10
| @@ -15,6 +15,7 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) { | |||||
| 15 | mockContent := mocks.NewMockBoxWidget(t) | 15 | mockContent := mocks.NewMockBoxWidget(t) | ||
| 16 | mockSpinner := mocks.NewMockSpinnerWidget(t) | 16 | mockSpinner := mocks.NewMockSpinnerWidget(t) | ||
| 17 | mockLogo := mocks.NewMockImageWidget(t) | 17 | mockLogo := mocks.NewMockImageWidget(t) | ||
| 18 | + | mockVersion := mocks.NewMockLabelWidget(t) | |||
| 18 | 19 | ||||
| 19 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | 20 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | ||
| 20 | mockContainer.EXPECT().SetHexpand(true).Once() | 21 | mockContainer.EXPECT().SetHexpand(true).Once() | ||
| @@ -32,26 +33,36 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) { | |||||
| 32 | mockContent.EXPECT().SetCanTarget(false).Once() | 33 | mockContent.EXPECT().SetCanTarget(false).Once() | ||
| 33 | mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | 34 | mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once() | ||
| 34 | 35 | ||||
| 35 | - | mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once() | |||
| 36 | - | mockSpinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 37 | - | mockSpinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 38 | - | mockSpinner.EXPECT().SetCanFocus(false).Once() | |||
| 39 | - | mockSpinner.EXPECT().SetCanTarget(false).Once() | |||
| 40 | - | mockSpinner.EXPECT().SetSizeRequest(32, 32).Once() | |||
| 41 | - | mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 42 | - | ||||
| 43 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | 36 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | ||
| 44 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | 37 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | ||
| 45 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | 38 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | ||
| 46 | mockLogo.EXPECT().SetCanFocus(false).Once() | 39 | mockLogo.EXPECT().SetCanFocus(false).Once() | ||
| 47 | mockLogo.EXPECT().SetCanTarget(false).Once() | 40 | mockLogo.EXPECT().SetCanTarget(false).Once() | ||
| 48 | - | mockLogo.EXPECT().SetSizeRequest(512, 512).Once() | 41 | + | mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() |
| 49 | - | mockLogo.EXPECT().SetPixelSize(512).Once() | 42 | + | mockLogo.EXPECT().SetPixelSize(mock.Anything).Once() |
| 50 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | 43 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | ||
| 51 | mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | 44 | mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | ||
| 52 | 45 | ||||
| 46 | + | mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once() | |||
| 47 | + | mockSpinner.EXPECT().SetHalign(mock.Anything).Once() | |||
| 48 | + | mockSpinner.EXPECT().SetValign(mock.Anything).Once() | |||
| 49 | + | mockSpinner.EXPECT().SetCanFocus(false).Once() | |||
| 50 | + | mockSpinner.EXPECT().SetCanTarget(false).Once() | |||
| 51 | + | mockSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() | |||
| 52 | + | mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once() | |||
| 53 | + | ||||
| 54 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockVersion).Once() | |||
| 55 | + | mockVersion.EXPECT().SetHalign(mock.Anything).Once() | |||
| 56 | + | mockVersion.EXPECT().SetValign(mock.Anything).Once() | |||
| 57 | + | mockVersion.EXPECT().SetCanFocus(false).Once() | |||
| 58 | + | mockVersion.EXPECT().SetCanTarget(false).Once() | |||
| 59 | + | mockVersion.EXPECT().SetMaxWidthChars(mock.Anything).Once() | |||
| 60 | + | mockVersion.EXPECT().SetEllipsize(mock.Anything).Once() | |||
| 61 | + | mockVersion.EXPECT().AddCssClass("loading-skeleton-version").Once() | |||
| 62 | + | ||||
| 53 | mockContent.EXPECT().Append(mockLogo).Once() | 63 | mockContent.EXPECT().Append(mockLogo).Once() | ||
| 54 | mockContent.EXPECT().Append(mockSpinner).Once() | 64 | mockContent.EXPECT().Append(mockSpinner).Once() | ||
| 65 | + | mockContent.EXPECT().Append(mockVersion).Once() | |||
| 55 | mockContainer.EXPECT().Append(mockContent).Once() | 66 | mockContainer.EXPECT().Append(mockContent).Once() | ||
| 56 | 67 | ||||
| 57 | mockContainer.EXPECT().SetVisible(true).Once() | 68 | mockContainer.EXPECT().SetVisible(true).Once() | ||
M internal/ui/component/pane_view_test.go +26 -13
| @@ -34,7 +34,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) { | |||||
| 34 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | 34 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | ||
| 35 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | 35 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | ||
| 36 | 36 | ||||
| 37 | - | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | 37 | + | setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) |
| 38 | 38 | ||||
| 39 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 39 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 40 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 40 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
| @@ -75,7 +75,7 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||||
| 75 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | 75 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | ||
| 76 | // SetChild should NOT be called when webview is nil | 76 | // SetChild should NOT be called when webview is nil | ||
| 77 | 77 | ||||
| 78 | - | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | 78 | + | setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) |
| 79 | 79 | ||||
| 80 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 80 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 81 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 81 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
| @@ -509,6 +509,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) { | |||||
| 509 | 509 | ||||
| 510 | // Helper function to setup common mock expectations for PaneView creation | 510 | // Helper function to setup common mock expectations for PaneView creation | ||
| 511 | func setupLoadingSkeletonMocks( | 511 | func setupLoadingSkeletonMocks( | ||
| 512 | + | t *testing.T, | |||
| 512 | mockFactory *mocks.MockWidgetFactory, | 513 | mockFactory *mocks.MockWidgetFactory, | ||
| 513 | mockOverlay *mocks.MockOverlayWidget, | 514 | mockOverlay *mocks.MockOverlayWidget, | ||
| 514 | mockLoadingContainer *mocks.MockBoxWidget, | 515 | mockLoadingContainer *mocks.MockBoxWidget, | ||
| @@ -516,6 +517,8 @@ func setupLoadingSkeletonMocks( | |||||
| 516 | mockLoadingSpinner *mocks.MockSpinnerWidget, | 517 | mockLoadingSpinner *mocks.MockSpinnerWidget, | ||
| 517 | mockLoadingLogo *mocks.MockImageWidget, | 518 | mockLoadingLogo *mocks.MockImageWidget, | ||
| 518 | ) { | 519 | ) { | ||
| 520 | + | mockLoadingVersion := mocks.NewMockLabelWidget(t) | |||
| 521 | + | ||||
| 519 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once() | 522 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once() | ||
| 520 | mockLoadingContainer.EXPECT().SetHexpand(true).Maybe() | 523 | mockLoadingContainer.EXPECT().SetHexpand(true).Maybe() | ||
| 521 | mockLoadingContainer.EXPECT().SetVexpand(true).Maybe() | 524 | mockLoadingContainer.EXPECT().SetVexpand(true).Maybe() | ||
| @@ -533,6 +536,16 @@ func setupLoadingSkeletonMocks( | |||||
| 533 | mockLoadingContent.EXPECT().SetCanTarget(false).Maybe() | 536 | mockLoadingContent.EXPECT().SetCanTarget(false).Maybe() | ||
| 534 | mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe() | 537 | mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe() | ||
| 535 | 538 | ||||
| 539 | + | mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once() | |||
| 540 | + | mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 541 | + | mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 542 | + | mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe() | |||
| 543 | + | mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe() | |||
| 544 | + | mockLoadingLogo.EXPECT().SetSizeRequest(256, 256).Maybe() | |||
| 545 | + | mockLoadingLogo.EXPECT().SetPixelSize(256).Maybe() | |||
| 546 | + | mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe() | |||
| 547 | + | mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |||
| 548 | + | ||||
| 536 | mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once() | 549 | mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once() | ||
| 537 | mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe() | 550 | mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe() | ||
| 538 | mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe() | 551 | mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe() | ||
| @@ -542,18 +555,18 @@ func setupLoadingSkeletonMocks( | |||||
| 542 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | 555 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | ||
| 543 | mockLoadingSpinner.EXPECT().Start().Maybe() | 556 | mockLoadingSpinner.EXPECT().Start().Maybe() | ||
| 544 | 557 | ||||
| 545 | - | mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once() | 558 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once() |
| 546 | - | mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe() | 559 | + | mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe() |
| 547 | - | mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe() | 560 | + | mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe() |
| 548 | - | mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe() | 561 | + | mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe() |
| 549 | - | mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe() | 562 | + | mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe() |
| 550 | - | mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe() | 563 | + | mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe() |
| 551 | - | mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe() | 564 | + | mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe() |
| 552 | - | mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe() | 565 | + | mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe() |
| 553 | - | mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |||
| 554 | 566 | ||||
| 555 | mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe() | 567 | mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe() | ||
| 556 | mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe() | 568 | mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe() | ||
| 569 | + | mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe() | |||
| 557 | mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe() | 570 | mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe() | ||
| 558 | 571 | ||||
| 559 | mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once() | 572 | mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once() | ||
| @@ -581,7 +594,7 @@ func setupPaneViewMocks( | |||||
| 581 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | 594 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | ||
| 582 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | 595 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | ||
| 583 | 596 | ||||
| 584 | - | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | 597 | + | setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) |
| 585 | 598 | ||||
| 586 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 599 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 587 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 600 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
| @@ -611,7 +624,7 @@ func setupPaneViewMocksNoWebView( | |||||
| 611 | mockOverlay.EXPECT().SetVisible(true).Once() | 624 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 612 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | 625 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | ||
| 613 | 626 | ||||
| 614 | - | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | 627 | + | setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) |
| 615 | 628 | ||||
| 616 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 629 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 617 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 630 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
M internal/ui/component/workspace_view.go +26 -1
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "sync" | 8 | "sync" | ||
| 9 | + | "sync/atomic" | |||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -56,6 +57,14 @@ type WorkspaceView struct { | |||||
| 56 | // Prevents hover focus from overriding keyboard-initiated focus changes | 57 | // Prevents hover focus from overriding keyboard-initiated focus changes | ||
| 57 | hoverSuppressedUntil time.Time | 58 | hoverSuppressedUntil time.Time | ||
| 58 | 59 | ||||
| 60 | + | // newPaneFocusLock suppresses hover focus after a new pane is created. | |||
| 61 | + | // Rule: new pane creation always wins focus. Hover focus is blocked | |||
| 62 | + | // until the user intentionally moves the mouse (motion event), at which | |||
| 63 | + | // point normal hover-follows-mouse behavior resumes. This prevents | |||
| 64 | + | // GTK's synthetic enter events (fired on widget rearrangement) from | |||
| 65 | + | // stealing focus away from a freshly created pane. | |||
| 66 | + | newPaneFocusLock atomic.Bool | |||
| 67 | + | ||||
| 59 | // Auto-open omnibox on new pane creation | 68 | // Auto-open omnibox on new pane creation | ||
| 60 | autoOpenOnNewPane bool | 69 | autoOpenOnNewPane bool | ||
| 61 | 70 | ||||
| @@ -93,6 +102,13 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||||
| 93 | } | 102 | } | ||
| 94 | }) | 103 | }) | ||
| 95 | 104 | ||||
| 105 | + | // Clear new-pane focus lock on intentional mouse motion. | |||
| 106 | + | // This allows hover-follows-mouse to resume after the user moves the | |||
| 107 | + | // mouse, while keeping focus on the new pane until that happens. | |||
| 108 | + | pv.SetOnMouseMotion(func() { | |||
| 109 | + | a.wv.newPaneFocusLock.Store(false) | |||
| 110 | + | }) | |||
| 111 | + | ||||
| 96 | // Set up hover callback for focus-follows-mouse | 112 | // Set up hover callback for focus-follows-mouse | ||
| 97 | pv.SetOnHover(func(paneID entity.PaneID) { | 113 | pv.SetOnHover(func(paneID entity.PaneID) { | ||
| 98 | // Skip if hover is suppressed (keyboard navigation just happened) | 114 | // Skip if hover is suppressed (keyboard navigation just happened) | ||
| @@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||||
| 100 | return | 116 | return | ||
| 101 | } | 117 | } | ||
| 102 | 118 | ||||
| 119 | + | // Skip if new pane was just created — focus stays on new pane | |||
| 120 | + | // until the user intentionally moves the mouse (see SetOnMouseMotion). | |||
| 121 | + | if a.wv.newPaneFocusLock.Load() { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | ||||
| 103 | // Skip if this pane is already active | 125 | // Skip if this pane is already active | ||
| 104 | if a.wv.GetActivePaneID() == paneID { | 126 | if a.wv.GetActivePaneID() == paneID { | ||
| 105 | return | 127 | return | ||
| @@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) { | |||||
| 566 | // NotifyNewPaneCreated handles new pane creation events. | 588 | // NotifyNewPaneCreated handles new pane creation events. | ||
| 567 | // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox. | 589 | // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox. | ||
| 568 | func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) { | 590 | func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) { | ||
| 569 | - | // Suppress hover focus so mouse position doesn't steal focus from new pane | 591 | + | // Lock hover focus until the user intentionally moves the mouse. |
| 592 | + | // New pane creation always wins focus; hover-follows-mouse resumes | |||
| 593 | + | // only after real mouse motion (cleared by PaneView.SetOnMouseMotion). | |||
| 594 | + | wv.newPaneFocusLock.Store(true) | |||
| 570 | wv.SuppressHover(KeyboardFocusSuppressDuration) | 595 | wv.SuppressHover(KeyboardFocusSuppressDuration) | ||
| 571 | wv.CancelAllPendingHovers() | 596 | wv.CancelAllPendingHovers() | ||
| 572 | 597 | ||||
M internal/ui/component/workspace_view_test.go +27 -14
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | func setupLoadingSkeletonMocksWorkspace( | 17 | func setupLoadingSkeletonMocksWorkspace( | ||
| 18 | + | t *testing.T, | |||
| 18 | mockFactory *mocks.MockWidgetFactory, | 19 | mockFactory *mocks.MockWidgetFactory, | ||
| 19 | mockOverlay *mocks.MockOverlayWidget, | 20 | mockOverlay *mocks.MockOverlayWidget, | ||
| 20 | mockLoadingContainer *mocks.MockBoxWidget, | 21 | mockLoadingContainer *mocks.MockBoxWidget, | ||
| @@ -22,6 +23,8 @@ func setupLoadingSkeletonMocksWorkspace( | |||||
| 22 | mockLoadingSpinner *mocks.MockSpinnerWidget, | 23 | mockLoadingSpinner *mocks.MockSpinnerWidget, | ||
| 23 | mockLoadingLogo *mocks.MockImageWidget, | 24 | mockLoadingLogo *mocks.MockImageWidget, | ||
| 24 | ) { | 25 | ) { | ||
| 26 | + | mockLoadingVersion := mocks.NewMockLabelWidget(t) | |||
| 27 | + | ||||
| 25 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once() | 28 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once() | ||
| 26 | mockLoadingContainer.EXPECT().SetHexpand(true).Maybe() | 29 | mockLoadingContainer.EXPECT().SetHexpand(true).Maybe() | ||
| 27 | mockLoadingContainer.EXPECT().SetVexpand(true).Maybe() | 30 | mockLoadingContainer.EXPECT().SetVexpand(true).Maybe() | ||
| @@ -39,27 +42,37 @@ func setupLoadingSkeletonMocksWorkspace( | |||||
| 39 | mockLoadingContent.EXPECT().SetCanTarget(false).Maybe() | 42 | mockLoadingContent.EXPECT().SetCanTarget(false).Maybe() | ||
| 40 | mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe() | 43 | mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe() | ||
| 41 | 44 | ||||
| 45 | + | mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once() | |||
| 46 | + | mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe() | |||
| 47 | + | mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe() | |||
| 48 | + | mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe() | |||
| 49 | + | mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe() | |||
| 50 | + | mockLoadingLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe() | |||
| 51 | + | mockLoadingLogo.EXPECT().SetPixelSize(mock.Anything).Maybe() | |||
| 52 | + | mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe() | |||
| 53 | + | mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |||
| 54 | + | ||||
| 42 | mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once() | 55 | mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once() | ||
| 43 | mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe() | 56 | mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe() | ||
| 44 | mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe() | 57 | mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe() | ||
| 45 | mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe() | 58 | mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe() | ||
| 46 | mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe() | 59 | mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe() | ||
| 47 | - | mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe() | 60 | + | mockLoadingSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe() |
| 48 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | 61 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | ||
| 49 | mockLoadingSpinner.EXPECT().Start().Maybe() | 62 | mockLoadingSpinner.EXPECT().Start().Maybe() | ||
| 50 | 63 | ||||
| 51 | - | mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once() | 64 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once() |
| 52 | - | mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe() | 65 | + | mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe() |
| 53 | - | mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe() | 66 | + | mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe() |
| 54 | - | mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe() | 67 | + | mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe() |
| 55 | - | mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe() | 68 | + | mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe() |
| 56 | - | mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe() | 69 | + | mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe() |
| 57 | - | mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe() | 70 | + | mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe() |
| 58 | - | mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe() | 71 | + | mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe() |
| 59 | - | mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |||
| 60 | 72 | ||||
| 61 | mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe() | 73 | mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe() | ||
| 62 | mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe() | 74 | mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe() | ||
| 75 | + | mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe() | |||
| 63 | mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe() | 76 | mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe() | ||
| 64 | 77 | ||||
| 65 | mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once() | 78 | mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once() | ||
| @@ -101,7 +114,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||||
| 101 | mockOverlay.EXPECT().SetVisible(true).Once() | 114 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 102 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() | 115 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() | ||
| 103 | 116 | ||||
| 104 | - | setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | 117 | + | setupLoadingSkeletonMocksWorkspace(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) |
| 105 | 118 | ||||
| 106 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | 119 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | ||
| 107 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | 120 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | ||
| @@ -166,7 +179,7 @@ func setupStackedLeafMocks( | |||||
| 166 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | 179 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | ||
| 167 | 180 | ||||
| 168 | // Close button click handler | 181 | // Close button click handler | ||
| 169 | - | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | 182 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once() |
| 170 | 183 | ||||
| 171 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | 184 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | ||
| 172 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | 185 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | ||
| @@ -274,9 +287,9 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||||
| 274 | mockStackBox2.EXPECT().SetVisible(true).Once() | 287 | mockStackBox2.EXPECT().SetVisible(true).Once() | ||
| 275 | mockPaned.EXPECT().SetStartChild(mockStackBox1).Once() | 288 | mockPaned.EXPECT().SetStartChild(mockStackBox1).Once() | ||
| 276 | mockPaned.EXPECT().SetEndChild(mockStackBox2).Once() | 289 | mockPaned.EXPECT().SetEndChild(mockStackBox2).Once() | ||
| 277 | - | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 290 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 278 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | 291 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | ||
| 279 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 292 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 280 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 293 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 281 | 294 | ||||
| 282 | mockBox.EXPECT().Append(mockPaned).Once() | 295 | mockBox.EXPECT().Append(mockPaned).Once() | ||
M internal/ui/coordinator/content.go +353 -103
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "net/url" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -20,12 +21,16 @@ import ( | |||||
| 20 | "github.com/bnema/dumber/internal/ui/component" | 21 | "github.com/bnema/dumber/internal/ui/component" | ||
| 21 | "github.com/bnema/dumber/internal/ui/input" | 22 | "github.com/bnema/dumber/internal/ui/input" | ||
| 22 | "github.com/bnema/dumber/internal/ui/layout" | 23 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 24 | + | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |||
| 23 | "github.com/jwijenbergh/puregotk/v4/gdk" | 25 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 26 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |||
| 24 | ) | 27 | ) | ||
| 25 | 28 | ||||
| 26 | const ( | 29 | const ( | ||
| 27 | - | aboutBlankURI = "about:blank" | 30 | + | aboutBlankURI = "about:blank" |
| 28 | - | logURLMaxLen = 80 | 31 | + | crashPageURI = "dumb://home/crash" |
| 32 | + | logURLMaxLen = 80 | |||
| 33 | + | oauthParentRefreshDebounce = 200 * time.Millisecond | |||
| 29 | 34 | ||||
| 30 | // Dark theme background color (#0a0a0b) as float32 RGBA values | 35 | // Dark theme background color (#0a0a0b) as float32 RGBA values | ||
| 31 | darkBgR = 0.039 | 36 | darkBgR = 0.039 | ||
| @@ -34,6 +39,51 @@ const ( | |||||
| 34 | darkBgA = 1.0 | 39 | darkBgA = 1.0 | ||
| 35 | ) | 40 | ) | ||
| 36 | 41 | ||||
| 42 | + | func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool { | |||
| 43 | + | switch reason { | |||
| 44 | + | case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue: | |||
| 45 | + | return true | |||
| 46 | + | case webkitlib.WebProcessTerminatedByApiValue: | |||
| 47 | + | return false | |||
| 48 | + | default: | |||
| 49 | + | return true | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func extractOriginalURIFromCrashPage(uri string) string { | |||
| 54 | + | if uri == "" { | |||
| 55 | + | return "" | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | parsed, err := url.Parse(uri) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return uri | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath { | |||
| 64 | + | return uri | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | if strings.Trim(parsed.Path, "/") != "crash" { | |||
| 68 | + | return uri | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | original := strings.TrimSpace(parsed.Query().Get("url")) | |||
| 72 | + | if original == "" { | |||
| 73 | + | return "" | |||
| 74 | + | } | |||
| 75 | + | return original | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func buildCrashPageURI(originalURI string) string { | |||
| 79 | + | if strings.TrimSpace(originalURI) == "" { | |||
| 80 | + | return crashPageURI | |||
| 81 | + | } | |||
| 82 | + | query := url.Values{} | |||
| 83 | + | query.Set("url", originalURI) | |||
| 84 | + | return crashPageURI + "?" + query.Encode() | |||
| 85 | + | } | |||
| 86 | + | ||||
| 37 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | 87 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | ||
| 38 | type ContentCoordinator struct { | 88 | type ContentCoordinator struct { | ||
| 39 | pool *webkit.WebViewPool | 89 | pool *webkit.WebViewPool | ||
| @@ -43,6 +93,7 @@ type ContentCoordinator struct { | |||||
| 43 | injector *webkit.ContentInjector | 93 | injector *webkit.ContentInjector | ||
| 44 | 94 | ||||
| 45 | webViews map[entity.PaneID]*webkit.WebView | 95 | webViews map[entity.PaneID]*webkit.WebView | ||
| 96 | + | webViewsMu sync.RWMutex | |||
| 46 | paneTitles map[entity.PaneID]string | 97 | paneTitles map[entity.PaneID]string | ||
| 47 | titleMu sync.RWMutex | 98 | titleMu sync.RWMutex | ||
| 48 | 99 | ||||
| @@ -87,6 +138,8 @@ type ContentCoordinator struct { | |||||
| 87 | factory *webkit.WebViewFactory | 138 | factory *webkit.WebViewFactory | ||
| 88 | popupConfig *config.PopupBehaviorConfig | 139 | popupConfig *config.PopupBehaviorConfig | ||
| 89 | pendingPopups map[port.WebViewID]*PendingPopup | 140 | pendingPopups map[port.WebViewID]*PendingPopup | ||
| 141 | + | popupOAuth map[port.WebViewID]*popupOAuthState | |||
| 142 | + | popupRefresh map[entity.PaneID]*time.Timer | |||
| 90 | popupMu sync.RWMutex | 143 | popupMu sync.RWMutex | ||
| 91 | 144 | ||||
| 92 | // Callback to insert popup into workspace (avoids circular dependency) | 145 | // Callback to insert popup into workspace (avoids circular dependency) | ||
| @@ -117,6 +170,14 @@ type pendingThemeUpdate struct { | |||||
| 117 | cssText string | 170 | cssText string | ||
| 118 | } | 171 | } | ||
| 119 | 172 | ||||
| 173 | + | type popupOAuthState struct { | |||
| 174 | + | ParentPaneID entity.PaneID | |||
| 175 | + | CallbackURI string | |||
| 176 | + | Success bool | |||
| 177 | + | Error bool | |||
| 178 | + | Seen bool | |||
| 179 | + | } | |||
| 180 | + | ||||
| 120 | // NewContentCoordinator creates a new ContentCoordinator. | 181 | // NewContentCoordinator creates a new ContentCoordinator. | ||
| 121 | func NewContentCoordinator( | 182 | func NewContentCoordinator( | ||
| 122 | ctx context.Context, | 183 | ctx context.Context, | ||
| @@ -140,6 +201,8 @@ func NewContentCoordinator( | |||||
| 140 | pendingReveal: make(map[entity.PaneID]bool), | 201 | pendingReveal: make(map[entity.PaneID]bool), | ||
| 141 | getActiveWS: getActiveWS, | 202 | getActiveWS: getActiveWS, | ||
| 142 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | 203 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | ||
| 204 | + | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |||
| 205 | + | popupRefresh: make(map[entity.PaneID]*time.Timer), | |||
| 143 | } | 206 | } | ||
| 144 | } | 207 | } | ||
| 145 | 208 | ||||
| @@ -199,7 +262,7 @@ func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) { | |||||
| 199 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | 262 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | ||
| 200 | log := logging.FromContext(ctx) | 263 | log := logging.FromContext(ctx) | ||
| 201 | 264 | ||||
| 202 | - | if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() { | 265 | + | if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() { |
| 203 | return wv, nil | 266 | return wv, nil | ||
| 204 | } | 267 | } | ||
| 205 | 268 | ||||
| @@ -208,7 +271,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 208 | } | 271 | } | ||
| 209 | 272 | ||||
| 210 | // Mark tab_created on first webview (first tab) | 273 | // Mark tab_created on first webview (first tab) | ||
| 211 | - | if len(c.webViews) == 0 { | 274 | + | if c.webViewCount() == 0 { |
| 212 | logging.Trace().Mark("tab_created") | 275 | logging.Trace().Mark("tab_created") | ||
| 213 | } | 276 | } | ||
| 214 | 277 | ||||
| @@ -218,7 +281,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 218 | } | 281 | } | ||
| 219 | logging.Trace().Mark("webview_acquired") | 282 | logging.Trace().Mark("webview_acquired") | ||
| 220 | 283 | ||||
| 221 | - | c.webViews[paneID] = wv | 284 | + | c.setWebViewLocked(paneID, wv) |
| 222 | c.setupWebViewCallbacks(ctx, paneID, wv) | 285 | c.setupWebViewCallbacks(ctx, paneID, wv) | ||
| 223 | 286 | ||||
| 224 | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | 287 | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | ||
| @@ -229,11 +292,10 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 229 | func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | 292 | func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | ||
| 230 | log := logging.FromContext(ctx) | 293 | log := logging.FromContext(ctx) | ||
| 231 | 294 | ||||
| 232 | - | wv, ok := c.webViews[paneID] | 295 | + | wv := c.deleteWebViewLocked(paneID) |
| 233 | - | if !ok || wv == nil { | 296 | + | if wv == nil { |
| 234 | return | 297 | return | ||
| 235 | } | 298 | } | ||
| 236 | - | delete(c.webViews, paneID) | |||
| 237 | c.clearPendingAppearance(paneID) | 299 | c.clearPendingAppearance(paneID) | ||
| 238 | 300 | ||||
| 239 | // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing), | 301 | // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing), | ||
| @@ -360,28 +422,64 @@ func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView | |||||
| 360 | return nil | 422 | return nil | ||
| 361 | } | 423 | } | ||
| 362 | 424 | ||||
| 363 | - | return c.webViews[pane.Pane.ID] | 425 | + | return c.getWebViewLocked(pane.Pane.ID) |
| 364 | } | 426 | } | ||
| 365 | 427 | ||||
| 366 | // GetWebView returns the WebView for a specific pane. | 428 | // GetWebView returns the WebView for a specific pane. | ||
| 367 | func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView { | 429 | func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView { | ||
| 368 | - | return c.webViews[paneID] | 430 | + | return c.getWebViewLocked(paneID) |
| 369 | } | 431 | } | ||
| 370 | 432 | ||||
| 371 | // RegisterPopupWebView registers a popup WebView that was created externally. | 433 | // RegisterPopupWebView registers a popup WebView that was created externally. | ||
| 372 | // This is used when popup tabs are created and the WebView needs to be tracked. | 434 | // This is used when popup tabs are created and the WebView needs to be tracked. | ||
| 373 | func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) { | 435 | func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) { | ||
| 374 | if wv != nil && paneID != "" { | 436 | if wv != nil && paneID != "" { | ||
| 375 | - | c.webViews[paneID] = wv | 437 | + | c.setWebViewLocked(paneID, wv) |
| 376 | } | 438 | } | ||
| 377 | } | 439 | } | ||
| 378 | 440 | ||||
| 441 | + | func (c *ContentCoordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView { | |||
| 442 | + | c.webViewsMu.RLock() | |||
| 443 | + | defer c.webViewsMu.RUnlock() | |||
| 444 | + | return c.webViews[paneID] | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func (c *ContentCoordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) { | |||
| 448 | + | c.webViewsMu.Lock() | |||
| 449 | + | c.webViews[paneID] = wv | |||
| 450 | + | c.webViewsMu.Unlock() | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func (c *ContentCoordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView { | |||
| 454 | + | c.webViewsMu.Lock() | |||
| 455 | + | defer c.webViewsMu.Unlock() | |||
| 456 | + | wv := c.webViews[paneID] | |||
| 457 | + | delete(c.webViews, paneID) | |||
| 458 | + | return wv | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func (c *ContentCoordinator) webViewCount() int { | |||
| 462 | + | c.webViewsMu.RLock() | |||
| 463 | + | defer c.webViewsMu.RUnlock() | |||
| 464 | + | return len(c.webViews) | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | func (c *ContentCoordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView { | |||
| 468 | + | c.webViewsMu.RLock() | |||
| 469 | + | defer c.webViewsMu.RUnlock() | |||
| 470 | + | snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews)) | |||
| 471 | + | for paneID, wv := range c.webViews { | |||
| 472 | + | snapshot[paneID] = wv | |||
| 473 | + | } | |||
| 474 | + | return snapshot | |||
| 475 | + | } | |||
| 476 | + | ||||
| 379 | // ApplyFiltersToAll applies content filters to all active webviews. | 477 | // ApplyFiltersToAll applies content filters to all active webviews. | ||
| 380 | // Called when filters become available after webviews were already created. | 478 | // Called when filters become available after webviews were already created. | ||
| 381 | func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) { | 479 | func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) { | ||
| 382 | log := logging.FromContext(ctx) | 480 | log := logging.FromContext(ctx) | ||
| 383 | 481 | ||||
| 384 | - | for paneID, wv := range c.webViews { | 482 | + | for paneID, wv := range c.snapshotWebViews() { |
| 385 | if wv != nil && !wv.IsDestroyed() { | 483 | if wv != nil && !wv.IsDestroyed() { | ||
| 386 | applier.ApplyTo(ctx, wv.UserContentManager()) | 484 | applier.ApplyTo(ctx, wv.UserContentManager()) | ||
| 387 | log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview") | 485 | log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview") | ||
| @@ -396,7 +494,7 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit. | |||||
| 396 | return | 494 | return | ||
| 397 | } | 495 | } | ||
| 398 | 496 | ||||
| 399 | - | for paneID, wv := range c.webViews { | 497 | + | for paneID, wv := range c.snapshotWebViews() { |
| 400 | if wv == nil || wv.IsDestroyed() { | 498 | if wv == nil || wv.IsDestroyed() { | ||
| 401 | continue | 499 | continue | ||
| 402 | } | 500 | } | ||
| @@ -740,10 +838,10 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||||
| 740 | 838 | ||||
| 741 | // Notify history persistence (get URL from WebView) | 839 | // Notify history persistence (get URL from WebView) | ||
| 742 | if c.onTitleUpdated != nil { | 840 | if c.onTitleUpdated != nil { | ||
| 743 | - | if wv := c.webViews[paneID]; wv != nil { | 841 | + | if wv := c.getWebViewLocked(paneID); wv != nil { |
| 744 | - | url := wv.URI() | 842 | + | currentURI := wv.URI() |
| 745 | - | if url != "" && title != "" { | 843 | + | if currentURI != "" && title != "" { |
| 746 | - | c.onTitleUpdated(ctx, paneID, url, title) | 844 | + | c.onTitleUpdated(ctx, paneID, currentURI, title) |
| 747 | } | 845 | } | ||
| 748 | } | 846 | } | ||
| 749 | } | 847 | } | ||
| @@ -782,12 +880,28 @@ func (c *ContentCoordinator) updateStackedPaneTitle( | |||||
| 782 | } | 880 | } | ||
| 783 | } | 881 | } | ||
| 784 | 882 | ||||
| 883 | + | // syncStackedTitle updates the stacked title bar for a pane if it's in a stack. | |||
| 884 | + | // Called from onLoadCommitted to keep titles in sync during navigation. | |||
| 885 | + | func (c *ContentCoordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) { | |||
| 886 | + | _, wsView := c.getActiveWS() | |||
| 887 | + | if wsView == nil { | |||
| 888 | + | return | |||
| 889 | + | } | |||
| 890 | + | tr := wsView.TreeRenderer() | |||
| 891 | + | if tr == nil { | |||
| 892 | + | return | |||
| 893 | + | } | |||
| 894 | + | if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil { | |||
| 895 | + | c.updateStackedPaneTitle(ctx, sv, paneID, title) | |||
| 896 | + | } | |||
| 897 | + | } | |||
| 898 | + | ||||
| 785 | // onFaviconChanged updates favicon tracking when a WebView's favicon changes. | 899 | // onFaviconChanged updates favicon tracking when a WebView's favicon changes. | ||
| 786 | func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) { | 900 | func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) { | ||
| 787 | log := logging.FromContext(ctx) | 901 | log := logging.FromContext(ctx) | ||
| 788 | 902 | ||||
| 789 | // Get current URI to extract domain for caching | 903 | // Get current URI to extract domain for caching | ||
| 790 | - | wv := c.webViews[paneID] | 904 | + | wv := c.getWebViewLocked(paneID) |
| 791 | if wv == nil { | 905 | if wv == nil { | ||
| 792 | return | 906 | return | ||
| 793 | } | 907 | } | ||
| @@ -851,27 +965,25 @@ func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter { | |||||
| 851 | // SetNavigationOrigin records the original URL before navigation starts. | 965 | // SetNavigationOrigin records the original URL before navigation starts. | ||
| 852 | // This allows caching favicons under both original and final domains | 966 | // This allows caching favicons under both original and final domains | ||
| 853 | // when cross-domain redirects occur (e.g., google.fr → google.com). | 967 | // when cross-domain redirects occur (e.g., google.fr → google.com). | ||
| 854 | - | func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) { | 968 | + | func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) { |
| 855 | c.navOriginMu.Lock() | 969 | c.navOriginMu.Lock() | ||
| 856 | - | c.navOrigins[paneID] = url | 970 | + | c.navOrigins[paneID] = uri |
| 857 | c.navOriginMu.Unlock() | 971 | c.navOriginMu.Unlock() | ||
| 858 | } | 972 | } | ||
| 859 | 973 | ||||
| 860 | // PreloadCachedFavicon checks the favicon cache and updates the stacked pane | 974 | // PreloadCachedFavicon checks the favicon cache and updates the stacked pane | ||
| 861 | // title bar immediately if a cached favicon exists for the URL. | 975 | // title bar immediately if a cached favicon exists for the URL. | ||
| 862 | // This provides instant favicon display without waiting for WebKit. | 976 | // This provides instant favicon display without waiting for WebKit. | ||
| 863 | - | func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) { | 977 | + | func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) { |
| 864 | - | if c.faviconAdapter == nil || url == "" { | 978 | + | if c.faviconAdapter == nil || uri == "" { |
| 865 | return | 979 | return | ||
| 866 | } | 980 | } | ||
| 867 | 981 | ||||
| 868 | // Check memory and disk cache (no external fetch) | 982 | // Check memory and disk cache (no external fetch) | ||
| 869 | - | texture := c.faviconAdapter.PreloadFromCache(url) | 983 | + | texture := c.faviconAdapter.PreloadFromCache(uri) |
| 870 | - | if texture == nil { | |||
| 871 | - | return | |||
| 872 | - | } | |||
| 873 | 984 | ||||
| 874 | - | // Update stacked pane favicon if applicable | 985 | + | // Update stacked pane favicon if applicable. |
| 986 | + | // A nil texture triggers the default icon fallback, which avoids stale favicons. | |||
| 875 | _, wsView := c.getActiveWS() | 987 | _, wsView := c.getActiveWS() | ||
| 876 | if wsView != nil { | 988 | if wsView != nil { | ||
| 877 | tr := wsView.TreeRenderer() | 989 | tr := wsView.TreeRenderer() | ||
| @@ -892,14 +1004,14 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 892 | log := logging.FromContext(ctx) | 1004 | log := logging.FromContext(ctx) | ||
| 893 | logging.Trace().Mark("load_committed") | 1005 | logging.Trace().Mark("load_committed") | ||
| 894 | 1006 | ||||
| 895 | - | url := wv.URI() | 1007 | + | uri := wv.URI() |
| 896 | - | if url == "" { | 1008 | + | if uri == "" { |
| 897 | return | 1009 | return | ||
| 898 | } | 1010 | } | ||
| 899 | 1011 | ||||
| 900 | // Set appropriate background color based on page type to prevent dark background bleeding. | 1012 | // Set appropriate background color based on page type to prevent dark background bleeding. | ||
| 901 | switch { | 1013 | switch { | ||
| 902 | - | case strings.HasPrefix(url, "dumb://"): | 1014 | + | case strings.HasPrefix(uri, "dumb://"): |
| 903 | // Internal pages: apply themed background | 1015 | // Internal pages: apply themed background | ||
| 904 | theme, ok := c.getCurrentTheme() | 1016 | theme, ok := c.getCurrentTheme() | ||
| 905 | if ok && theme.prefersDark { | 1017 | if ok && theme.prefersDark { | ||
| @@ -907,7 +1019,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 907 | } else { | 1019 | } else { | ||
| 908 | wv.ResetBackgroundToDefault() | 1020 | wv.ResetBackgroundToDefault() | ||
| 909 | } | 1021 | } | ||
| 910 | - | case strings.HasPrefix(url, "about:"): | 1022 | + | case strings.HasPrefix(uri, "about:"): |
| 911 | // Keep pool background (no action) | 1023 | // Keep pool background (no action) | ||
| 912 | default: | 1024 | default: | ||
| 913 | // External pages: white background | 1025 | // External pages: white background | ||
| @@ -919,7 +1031,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 919 | // Skip showing if this is about:blank but the pane is loading a different URL | 1031 | // Skip showing if this is about:blank but the pane is loading a different URL | ||
| 920 | // This prevents the brief flash of about:blank during initial navigation | 1032 | // This prevents the brief flash of about:blank during initial navigation | ||
| 921 | shouldShow := true | 1033 | shouldShow := true | ||
| 922 | - | if url == aboutBlankURI { | 1034 | + | if uri == aboutBlankURI { |
| 923 | // Get the pane's intended URI from the workspace | 1035 | // Get the pane's intended URI from the workspace | ||
| 924 | ws, _ := c.getActiveWS() | 1036 | ws, _ := c.getActiveWS() | ||
| 925 | if ws != nil { | 1037 | if ws != nil { | ||
| @@ -950,15 +1062,22 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 950 | 1062 | ||||
| 951 | c.markPendingReveal(paneID) | 1063 | c.markPendingReveal(paneID) | ||
| 952 | if wv.EstimatedProgress() > 0 { | 1064 | if wv.EstimatedProgress() > 0 { | ||
| 953 | - | c.revealIfPending(ctx, paneID, url, "progress-after-commit") | 1065 | + | c.revealIfPending(ctx, paneID, uri, "progress-after-commit") |
| 954 | } | 1066 | } | ||
| 955 | 1067 | ||||
| 956 | // Update domain model with current URI for session snapshots | 1068 | // Update domain model with current URI for session snapshots | ||
| 957 | - | c.updatePaneURI(paneID, url) | 1069 | + | c.updatePaneURI(paneID, uri) |
| 1070 | + | ||||
| 1071 | + | // Sync StackedView title bar with the WebView's current title. | |||
| 1072 | + | // This keeps the stacked title bar up-to-date immediately on navigation, | |||
| 1073 | + | // before the asynchronous notify::title signal fires. | |||
| 1074 | + | if title := wv.Title(); title != "" { | |||
| 1075 | + | c.syncStackedTitle(ctx, paneID, title) | |||
| 1076 | + | } | |||
| 958 | 1077 | ||||
| 959 | // Record history - URI is guaranteed to be correct at LoadCommitted | 1078 | // Record history - URI is guaranteed to be correct at LoadCommitted | ||
| 960 | if c.onHistoryRecord != nil { | 1079 | if c.onHistoryRecord != nil { | ||
| 961 | - | c.onHistoryRecord(ctx, paneID, url) | 1080 | + | c.onHistoryRecord(ctx, paneID, uri) |
| 962 | } | 1081 | } | ||
| 963 | 1082 | ||||
| 964 | // Apply zoom | 1083 | // Apply zoom | ||
| @@ -966,7 +1085,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||||
| 966 | return | 1085 | return | ||
| 967 | } | 1086 | } | ||
| 968 | 1087 | ||||
| 969 | - | domain, err := usecase.ExtractDomain(url) | 1088 | + | domain, err := usecase.ExtractDomain(uri) |
| 970 | if err != nil { | 1089 | if err != nil { | ||
| 971 | return | 1090 | return | ||
| 972 | } | 1091 | } | ||
| @@ -997,24 +1116,21 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID | |||||
| 997 | 1116 | ||||
| 998 | // onSPANavigation records history when URL changes via JavaScript (History API). | 1117 | // onSPANavigation records history when URL changes via JavaScript (History API). | ||
| 999 | // This handles SPA navigation like YouTube search, where the URL changes without a page load. | 1118 | // This handles SPA navigation like YouTube search, where the URL changes without a page load. | ||
| 1000 | - | func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) { | 1119 | + | func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) { |
| 1001 | - | log := logging.FromContext(ctx) | |||
| 1002 | - | log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected") | |||
| 1003 | - | ||||
| 1004 | // Update domain model with current URI for session snapshots | 1120 | // Update domain model with current URI for session snapshots | ||
| 1005 | - | c.updatePaneURI(paneID, url) | 1121 | + | c.updatePaneURI(paneID, uri) |
| 1006 | 1122 | ||||
| 1007 | // Record history for SPA navigation | 1123 | // Record history for SPA navigation | ||
| 1008 | if c.onHistoryRecord != nil { | 1124 | if c.onHistoryRecord != nil { | ||
| 1009 | - | c.onHistoryRecord(ctx, paneID, url) | 1125 | + | c.onHistoryRecord(ctx, paneID, uri) |
| 1010 | } | 1126 | } | ||
| 1011 | } | 1127 | } | ||
| 1012 | 1128 | ||||
| 1013 | // updatePaneURI updates the pane's URI in the domain model. | 1129 | // updatePaneURI updates the pane's URI in the domain model. | ||
| 1014 | // This is called on navigation so that session snapshots capture the current URL. | 1130 | // This is called on navigation so that session snapshots capture the current URL. | ||
| 1015 | - | func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) { | 1131 | + | func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, uri string) { |
| 1016 | if c.onPaneURIUpdated != nil { | 1132 | if c.onPaneURIUpdated != nil { | ||
| 1017 | - | c.onPaneURIUpdated(paneID, url) | 1133 | + | c.onPaneURIUpdated(paneID, uri) |
| 1018 | } | 1134 | } | ||
| 1019 | } | 1135 | } | ||
| 1020 | 1136 | ||||
| @@ -1090,7 +1206,7 @@ func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) { | |||||
| 1090 | c.revealMu.Unlock() | 1206 | c.revealMu.Unlock() | ||
| 1091 | } | 1207 | } | ||
| 1092 | 1208 | ||||
| 1093 | - | func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) { | 1209 | + | func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) { |
| 1094 | c.revealMu.Lock() | 1210 | c.revealMu.Lock() | ||
| 1095 | pending := c.pendingReveal[paneID] | 1211 | pending := c.pendingReveal[paneID] | ||
| 1096 | if pending { | 1212 | if pending { | ||
| @@ -1102,7 +1218,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity. | |||||
| 1102 | return | 1218 | return | ||
| 1103 | } | 1219 | } | ||
| 1104 | 1220 | ||||
| 1105 | - | wv := c.webViews[paneID] | 1221 | + | wv := c.getWebViewLocked(paneID) |
| 1106 | if wv == nil || wv.IsDestroyed() { | 1222 | if wv == nil || wv.IsDestroyed() { | ||
| 1107 | return | 1223 | return | ||
| 1108 | } | 1224 | } | ||
| @@ -1112,7 +1228,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity. | |||||
| 1112 | logging.FromContext(ctx). | 1228 | logging.FromContext(ctx). | ||
| 1113 | Debug(). | 1229 | Debug(). | ||
| 1114 | Str("pane_id", string(paneID)). | 1230 | Str("pane_id", string(paneID)). | ||
| 1115 | - | Str("url", url). | 1231 | + | Str("uri", uri). |
| 1116 | Str("reason", reason). | 1232 | Str("reason", reason). | ||
| 1117 | Msg("webview revealed") | 1233 | Msg("webview revealed") | ||
| 1118 | } | 1234 | } | ||
| @@ -1294,7 +1410,7 @@ func (c *ContentCoordinator) handlePopupCreate( | |||||
| 1294 | } | 1410 | } | ||
| 1295 | 1411 | ||||
| 1296 | // Register WebView in our map (after successful insertion) | 1412 | // Register WebView in our map (after successful insertion) | ||
| 1297 | - | c.webViews[paneID] = popupWV | 1413 | + | c.setWebViewLocked(paneID, popupWV) |
| 1298 | 1414 | ||||
| 1299 | // Setup standard callbacks (after successful insertion to avoid leak) | 1415 | // Setup standard callbacks (after successful insertion to avoid leak) | ||
| 1300 | c.setupWebViewCallbacks(ctx, paneID, popupWV) | 1416 | c.setupWebViewCallbacks(ctx, paneID, popupWV) | ||
| @@ -1302,7 +1418,8 @@ func (c *ContentCoordinator) handlePopupCreate( | |||||
| 1302 | // Setup OAuth auto-close if configured | 1418 | // Setup OAuth auto-close if configured | ||
| 1303 | if hasConfig && oauthEnabled && isOAuth { | 1419 | if hasConfig && oauthEnabled && isOAuth { | ||
| 1304 | popupPane.AutoClose = true | 1420 | popupPane.AutoClose = true | ||
| 1305 | - | c.setupOAuthAutoClose(ctx, paneID, popupWV) | 1421 | + | c.trackOAuthPopup(popupID, parentPaneID) |
| 1422 | + | c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV) | |||
| 1306 | log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup") | 1423 | log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup") | ||
| 1307 | } | 1424 | } | ||
| 1308 | 1425 | ||||
| @@ -1326,9 +1443,9 @@ func (c *ContentCoordinator) handlePopupCreate( | |||||
| 1326 | popupWV.OnReadyToShow = func() { | 1443 | popupWV.OnReadyToShow = func() { | ||
| 1327 | c.handlePopupReadyToShow(ctx, popupID) | 1444 | c.handlePopupReadyToShow(ctx, popupID) | ||
| 1328 | } | 1445 | } | ||
| 1329 | - | popupWV.OnClose = func() { | 1446 | + | popupWV.OnClose = composeOnClose(popupWV.OnClose, func() { |
| 1330 | c.handlePopupClose(ctx, popupID) | 1447 | c.handlePopupClose(ctx, popupID) | ||
| 1331 | - | } | 1448 | + | }) |
| 1332 | 1449 | ||||
| 1333 | log.Info(). | 1450 | log.Info(). | ||
| 1334 | Uint64("popup_id", uint64(popupID)). | 1451 | Uint64("popup_id", uint64(popupID)). | ||
| @@ -1389,6 +1506,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||||
| 1389 | c.popupMu.Unlock() | 1506 | c.popupMu.Unlock() | ||
| 1390 | 1507 | ||||
| 1391 | if wasPending && pending != nil { | 1508 | if wasPending && pending != nil { | ||
| 1509 | + | c.handlePopupOAuthClose(ctx, popupID) | |||
| 1392 | // Was never shown, just destroy | 1510 | // Was never shown, just destroy | ||
| 1393 | pending.WebView.Destroy() | 1511 | pending.WebView.Destroy() | ||
| 1394 | log.Debug().Msg("destroyed pending popup that was never shown") | 1512 | log.Debug().Msg("destroyed pending popup that was never shown") | ||
| @@ -1405,6 +1523,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||||
| 1405 | } | 1523 | } | ||
| 1406 | 1524 | ||||
| 1407 | if paneID == "" { | 1525 | if paneID == "" { | ||
| 1526 | + | c.handlePopupOAuthClose(ctx, popupID) | |||
| 1408 | log.Warn().Msg("popup close: could not find pane for webview") | 1527 | log.Warn().Msg("popup close: could not find pane for webview") | ||
| 1409 | return | 1528 | return | ||
| 1410 | } | 1529 | } | ||
| @@ -1416,6 +1535,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||||
| 1416 | } | 1535 | } | ||
| 1417 | } | 1536 | } | ||
| 1418 | 1537 | ||||
| 1538 | + | c.handlePopupOAuthClose(ctx, popupID) | |||
| 1539 | + | ||||
| 1419 | // Release the WebView (this will clean up tracking) | 1540 | // Release the WebView (this will clean up tracking) | ||
| 1420 | c.ReleaseWebView(ctx, paneID) | 1541 | c.ReleaseWebView(ctx, paneID) | ||
| 1421 | 1542 | ||||
| @@ -1424,6 +1545,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||||
| 1424 | 1545 | ||||
| 1425 | // setupWebViewCallbacks configures standard callbacks and popup handling. | 1546 | // setupWebViewCallbacks configures standard callbacks and popup handling. | ||
| 1426 | func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | 1547 | func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | ||
| 1548 | + | log := logging.FromContext(ctx) | |||
| 1549 | + | ||||
| 1427 | // Title changes | 1550 | // Title changes | ||
| 1428 | wv.OnTitleChanged = func(title string) { | 1551 | wv.OnTitleChanged = func(title string) { | ||
| 1429 | c.onTitleChanged(ctx, paneID, title) | 1552 | c.onTitleChanged(ctx, paneID, title) | ||
| @@ -1453,7 +1576,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 1453 | 1576 | ||||
| 1454 | // SPA navigation and external scheme handling | 1577 | // SPA navigation and external scheme handling | ||
| 1455 | wv.OnURIChanged = func(uri string) { | 1578 | wv.OnURIChanged = func(uri string) { | ||
| 1456 | - | log := logging.FromContext(ctx) | |||
| 1457 | if uri == "" { | 1579 | if uri == "" { | ||
| 1458 | return | 1580 | return | ||
| 1459 | } | 1581 | } | ||
| @@ -1461,12 +1583,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 1461 | // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.) | 1583 | // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.) | ||
| 1462 | // These are typically triggered by JavaScript redirects (window.location) | 1584 | // These are typically triggered by JavaScript redirects (window.location) | ||
| 1463 | isExternal := urlutil.IsExternalScheme(uri) | 1585 | isExternal := urlutil.IsExternalScheme(uri) | ||
| 1464 | - | log.Debug(). | |||
| 1465 | - | Str("pane_id", string(paneID)). | |||
| 1466 | - | Str("uri", uri). | |||
| 1467 | - | Bool("is_external", isExternal). | |||
| 1468 | - | Bool("is_loading", wv.IsLoading()). | |||
| 1469 | - | Msg("OnURIChanged") | |||
| 1470 | 1586 | ||||
| 1471 | if isExternal { | 1587 | if isExternal { | ||
| 1472 | log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally") | 1588 | log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally") | ||
| @@ -1500,6 +1616,36 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 1500 | c.onLinkHover(paneID, uri) | 1616 | c.onLinkHover(paneID, uri) | ||
| 1501 | } | 1617 | } | ||
| 1502 | 1618 | ||||
| 1619 | + | wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) { | |||
| 1620 | + | originalURI := extractOriginalURIFromCrashPage(uri) | |||
| 1621 | + | if !shouldRenderCrashPage(reason) { | |||
| 1622 | + | log.Info(). | |||
| 1623 | + | Str("pane_id", string(paneID)). | |||
| 1624 | + | Str("reason", reasonLabel). | |||
| 1625 | + | Str("uri", uri). | |||
| 1626 | + | Msg("web process termination handled without crash page") | |||
| 1627 | + | return | |||
| 1628 | + | } | |||
| 1629 | + | ||||
| 1630 | + | crashURI := buildCrashPageURI(originalURI) | |||
| 1631 | + | log.Warn(). | |||
| 1632 | + | Str("pane_id", string(paneID)). | |||
| 1633 | + | Str("reason", reasonLabel). | |||
| 1634 | + | Str("uri", uri). | |||
| 1635 | + | Str("crash_uri", crashURI). | |||
| 1636 | + | Msg("web process terminated, redirecting to crash page") | |||
| 1637 | + | ||||
| 1638 | + | if err := wv.LoadURI(ctx, crashURI); err != nil { | |||
| 1639 | + | log.Error(). | |||
| 1640 | + | Err(err). | |||
| 1641 | + | Str("pane_id", string(paneID)). | |||
| 1642 | + | Str("reason", reasonLabel). | |||
| 1643 | + | Str("uri", uri). | |||
| 1644 | + | Str("crash_uri", crashURI). | |||
| 1645 | + | Msg("failed to load crash page after web process termination") | |||
| 1646 | + | } | |||
| 1647 | + | } | |||
| 1648 | + | ||||
| 1503 | // Fullscreen handlers for idle inhibition | 1649 | // Fullscreen handlers for idle inhibition | ||
| 1504 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | 1650 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | ||
| 1505 | 1651 | ||||
| @@ -1512,12 +1658,19 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 1512 | // For providers using postMessage (like Google Sign-In), we rely on the provider calling | 1658 | // For providers using postMessage (like Google Sign-In), we rely on the provider calling | ||
| 1513 | // window.close() which triggers WebKit's close signal. | 1659 | // window.close() which triggers WebKit's close signal. | ||
| 1514 | // A long safety timeout (30s) catches popups that get stuck. | 1660 | // A long safety timeout (30s) catches popups that get stuck. | ||
| 1515 | - | func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | 1661 | + | func (c *ContentCoordinator) setupOAuthAutoClose( |
| 1662 | + | ctx context.Context, | |||
| 1663 | + | paneID entity.PaneID, | |||
| 1664 | + | popupID port.WebViewID, | |||
| 1665 | + | wv *webkit.WebView, | |||
| 1666 | + | ) { | |||
| 1516 | log := logging.FromContext(ctx) | 1667 | log := logging.FromContext(ctx) | ||
| 1517 | 1668 | ||||
| 1518 | // Safety timeout - only triggers if popup gets stuck (provider should close via window.close) | 1669 | // Safety timeout - only triggers if popup gets stuck (provider should close via window.close) | ||
| 1519 | var safetyTimer *time.Timer | 1670 | var safetyTimer *time.Timer | ||
| 1520 | var safetyTimerMu sync.Mutex | 1671 | var safetyTimerMu sync.Mutex | ||
| 1672 | + | var cancelSafetyTimerOnce sync.Once | |||
| 1673 | + | var requestCloseOnce sync.Once | |||
| 1521 | const oauthSafetyTimeout = 30 * time.Second | 1674 | const oauthSafetyTimeout = 30 * time.Second | ||
| 1522 | 1675 | ||||
| 1523 | startSafetyTimer := func() { | 1676 | startSafetyTimer := func() { | ||
| @@ -1535,69 +1688,166 @@ func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID ent | |||||
| 1535 | } | 1688 | } | ||
| 1536 | 1689 | ||||
| 1537 | cancelSafetyTimer := func() { | 1690 | cancelSafetyTimer := func() { | ||
| 1538 | - | safetyTimerMu.Lock() | 1691 | + | cancelSafetyTimerOnce.Do(func() { |
| 1539 | - | defer safetyTimerMu.Unlock() | 1692 | + | safetyTimerMu.Lock() |
| 1540 | - | if safetyTimer != nil { | 1693 | + | defer safetyTimerMu.Unlock() |
| 1541 | - | safetyTimer.Stop() | 1694 | + | if safetyTimer != nil { |
| 1542 | - | safetyTimer = nil | 1695 | + | safetyTimer.Stop() |
| 1543 | - | } | 1696 | + | safetyTimer = nil |
| 1697 | + | } | |||
| 1698 | + | }) | |||
| 1544 | } | 1699 | } | ||
| 1545 | 1700 | ||||
| 1546 | - | // Start safety timer immediately | 1701 | + | requestOAuthClose := func(uri string, reason string) { |
| 1547 | - | startSafetyTimer() | 1702 | + | c.capturePopupOAuthState(popupID, uri) |
| 1548 | - | 1703 | + | cancelSafetyTimer() | |
| 1549 | - | // Wrap the existing OnURIChanged to also check for OAuth callbacks | 1704 | + | log.Info(). |
| 1550 | - | originalURIChanged := wv.OnURIChanged | 1705 | + | Str("pane", string(paneID)). |
| 1551 | - | wv.OnURIChanged = func(uri string) { | 1706 | + | Str("reason", reason). |
| 1552 | - | // Call original handler first | 1707 | + | Msg("oauth callback detected, closing") |
| 1553 | - | if originalURIChanged != nil { | 1708 | + | requestCloseOnce.Do(func() { |
| 1554 | - | originalURIChanged(uri) | |||
| 1555 | - | } | |||
| 1556 | - | ||||
| 1557 | - | // Check for OAuth callback (URL-based detection) | |||
| 1558 | - | if ShouldAutoClose(uri) { | |||
| 1559 | - | cancelSafetyTimer() | |||
| 1560 | - | log.Info().Str("pane", string(paneID)).Msg("oauth callback detected, closing") | |||
| 1561 | go func() { | 1709 | go func() { | ||
| 1562 | time.Sleep(500 * time.Millisecond) | 1710 | time.Sleep(500 * time.Millisecond) | ||
| 1563 | if wv != nil && !wv.IsDestroyed() { | 1711 | if wv != nil && !wv.IsDestroyed() { | ||
| 1564 | wv.Close() | 1712 | wv.Close() | ||
| 1565 | } | 1713 | } | ||
| 1566 | }() | 1714 | }() | ||
| 1567 | - | } | 1715 | + | }) |
| 1568 | } | 1716 | } | ||
| 1569 | 1717 | ||||
| 1570 | - | // Monitor load events for URL-based detection | 1718 | + | // Start safety timer immediately. |
| 1571 | - | originalLoadChanged := wv.OnLoadChanged | 1719 | + | startSafetyTimer() |
| 1572 | - | wv.OnLoadChanged = func(event webkit.LoadEvent) { | 1720 | + | |
| 1573 | - | if originalLoadChanged != nil { | 1721 | + | // Wrap OnURIChanged to check for OAuth callbacks. |
| 1574 | - | originalLoadChanged(event) | 1722 | + | wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) { |
| 1723 | + | if ShouldAutoClose(uri) { | |||
| 1724 | + | requestOAuthClose(uri, "uri_changed") | |||
| 1575 | } | 1725 | } | ||
| 1726 | + | }) | |||
| 1576 | 1727 | ||||
| 1577 | - | // Check URL on commit for immediate detection | 1728 | + | // Monitor load events for URL-based detection. |
| 1729 | + | wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) { | |||
| 1578 | if event == webkit.LoadCommitted { | 1730 | if event == webkit.LoadCommitted { | ||
| 1579 | uri := wv.URI() | 1731 | uri := wv.URI() | ||
| 1580 | if ShouldAutoClose(uri) { | 1732 | if ShouldAutoClose(uri) { | ||
| 1581 | - | cancelSafetyTimer() | 1733 | + | requestOAuthClose(uri, "load_committed") |
| 1582 | - | log.Info().Str("pane", string(paneID)).Msg("oauth callback on load, closing") | |||
| 1583 | - | go func() { | |||
| 1584 | - | time.Sleep(500 * time.Millisecond) | |||
| 1585 | - | if wv != nil && !wv.IsDestroyed() { | |||
| 1586 | - | wv.Close() | |||
| 1587 | - | } | |||
| 1588 | - | }() | |||
| 1589 | } | 1734 | } | ||
| 1590 | } | 1735 | } | ||
| 1591 | - | } | 1736 | + | }) |
| 1592 | 1737 | ||||
| 1593 | - | // Wrap OnClose to cancel safety timer when popup closes normally | 1738 | + | // Cancel safety timer on any close path. |
| 1594 | - | originalOnClose := wv.OnClose | 1739 | + | wv.OnClose = composeOnClose(func() { |
| 1595 | - | wv.OnClose = func() { | |||
| 1596 | cancelSafetyTimer() | 1740 | cancelSafetyTimer() | ||
| 1597 | - | if originalOnClose != nil { | 1741 | + | }, wv.OnClose) |
| 1598 | - | originalOnClose() | 1742 | + | } |
| 1599 | - | } | 1743 | + | |
| 1744 | + | func (c *ContentCoordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) { | |||
| 1745 | + | c.popupMu.Lock() | |||
| 1746 | + | defer c.popupMu.Unlock() | |||
| 1747 | + | if c.popupOAuth == nil { | |||
| 1748 | + | c.popupOAuth = make(map[port.WebViewID]*popupOAuthState) | |||
| 1749 | + | } | |||
| 1750 | + | c.popupOAuth[popupID] = &popupOAuthState{ | |||
| 1751 | + | ParentPaneID: parentPaneID, | |||
| 1752 | + | } | |||
| 1753 | + | } | |||
| 1754 | + | ||||
| 1755 | + | func (c *ContentCoordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) { | |||
| 1756 | + | c.popupMu.Lock() | |||
| 1757 | + | defer c.popupMu.Unlock() | |||
| 1758 | + | ||||
| 1759 | + | state, ok := c.popupOAuth[popupID] | |||
| 1760 | + | if !ok { | |||
| 1761 | + | return | |||
| 1762 | + | } | |||
| 1763 | + | ||||
| 1764 | + | state.Seen = true | |||
| 1765 | + | state.CallbackURI = uri | |||
| 1766 | + | state.Success = IsOAuthSuccess(uri) | |||
| 1767 | + | state.Error = IsOAuthError(uri) | |||
| 1768 | + | } | |||
| 1769 | + | ||||
| 1770 | + | func (c *ContentCoordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) { | |||
| 1771 | + | log := logging.FromContext(ctx) | |||
| 1772 | + | ||||
| 1773 | + | c.popupMu.Lock() | |||
| 1774 | + | state, ok := c.popupOAuth[popupID] | |||
| 1775 | + | if ok { | |||
| 1776 | + | delete(c.popupOAuth, popupID) | |||
| 1777 | + | } | |||
| 1778 | + | c.popupMu.Unlock() | |||
| 1779 | + | ||||
| 1780 | + | if !ok || state == nil || !state.Seen { | |||
| 1781 | + | return | |||
| 1782 | + | } | |||
| 1783 | + | ||||
| 1784 | + | log.Debug(). | |||
| 1785 | + | Uint64("popup_id", uint64(popupID)). | |||
| 1786 | + | Str("parent_pane_id", string(state.ParentPaneID)). | |||
| 1787 | + | Bool("oauth_success", state.Success). | |||
| 1788 | + | Bool("oauth_error", state.Error). | |||
| 1789 | + | Msg("popup oauth result captured on close") | |||
| 1790 | + | ||||
| 1791 | + | if !state.Success || state.ParentPaneID == "" { | |||
| 1792 | + | return | |||
| 1793 | + | } | |||
| 1794 | + | ||||
| 1795 | + | c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID) | |||
| 1796 | + | } | |||
| 1797 | + | ||||
| 1798 | + | func (c *ContentCoordinator) scheduleParentPaneRefresh( | |||
| 1799 | + | ctx context.Context, | |||
| 1800 | + | parentPaneID entity.PaneID, | |||
| 1801 | + | popupID port.WebViewID, | |||
| 1802 | + | ) { | |||
| 1803 | + | c.popupMu.Lock() | |||
| 1804 | + | if c.popupRefresh == nil { | |||
| 1805 | + | c.popupRefresh = make(map[entity.PaneID]*time.Timer) | |||
| 1806 | + | } | |||
| 1807 | + | if existing := c.popupRefresh[parentPaneID]; existing != nil { | |||
| 1808 | + | existing.Stop() | |||
| 1809 | + | } | |||
| 1810 | + | c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() { | |||
| 1811 | + | c.popupMu.Lock() | |||
| 1812 | + | delete(c.popupRefresh, parentPaneID) | |||
| 1813 | + | c.popupMu.Unlock() | |||
| 1814 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1815 | + | c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID) | |||
| 1816 | + | return false | |||
| 1817 | + | }) | |||
| 1818 | + | glib.IdleAdd(&cb, 0) | |||
| 1819 | + | }) | |||
| 1820 | + | c.popupMu.Unlock() | |||
| 1821 | + | } | |||
| 1822 | + | ||||
| 1823 | + | func (c *ContentCoordinator) refreshPaneAfterOAuth( | |||
| 1824 | + | ctx context.Context, | |||
| 1825 | + | parentPaneID entity.PaneID, | |||
| 1826 | + | popupID port.WebViewID, | |||
| 1827 | + | ) { | |||
| 1828 | + | log := logging.FromContext(ctx) | |||
| 1829 | + | wv := c.getWebViewLocked(parentPaneID) | |||
| 1830 | + | if wv == nil || wv.IsDestroyed() { | |||
| 1831 | + | log.Debug(). | |||
| 1832 | + | Str("parent_pane_id", string(parentPaneID)). | |||
| 1833 | + | Uint64("popup_id", uint64(popupID)). | |||
| 1834 | + | Msg("skipping parent pane refresh after oauth close: parent webview unavailable") | |||
| 1835 | + | return | |||
| 1836 | + | } | |||
| 1837 | + | ||||
| 1838 | + | if err := wv.Reload(ctx); err != nil { | |||
| 1839 | + | log.Warn(). | |||
| 1840 | + | Err(err). | |||
| 1841 | + | Str("parent_pane_id", string(parentPaneID)). | |||
| 1842 | + | Uint64("popup_id", uint64(popupID)). | |||
| 1843 | + | Msg("failed parent pane refresh after oauth popup close") | |||
| 1844 | + | return | |||
| 1600 | } | 1845 | } | ||
| 1846 | + | ||||
| 1847 | + | log.Info(). | |||
| 1848 | + | Str("parent_pane_id", string(parentPaneID)). | |||
| 1849 | + | Uint64("popup_id", uint64(popupID)). | |||
| 1850 | + | Msg("refreshed parent pane after oauth popup success") | |||
| 1601 | } | 1851 | } | ||
| 1602 | 1852 | ||||
| 1603 | // handleLinkMiddleClick handles middle-click / Ctrl+click on links. | 1853 | // handleLinkMiddleClick handles middle-click / Ctrl+click on links. | ||
| @@ -1643,7 +1893,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||||
| 1643 | newPane.URI = uri | 1893 | newPane.URI = uri | ||
| 1644 | 1894 | ||||
| 1645 | // Register WebView | 1895 | // Register WebView | ||
| 1646 | - | c.webViews[paneID] = newWV | 1896 | + | c.setWebViewLocked(paneID, newWV) |
| 1647 | 1897 | ||||
| 1648 | // Setup callbacks | 1898 | // Setup callbacks | ||
| 1649 | c.setupWebViewCallbacks(ctx, paneID, newWV) | 1899 | c.setupWebViewCallbacks(ctx, paneID, newWV) | ||
A internal/ui/coordinator/content_termination_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestShouldRenderCrashPage(t *testing.T) { | |||
| 11 | + | assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessCrashedValue)) | |||
| 12 | + | assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessExceededMemoryLimitValue)) | |||
| 13 | + | assert.False(t, shouldRenderCrashPage(webkitlib.WebProcessTerminatedByApiValue)) | |||
| 14 | + | assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessTerminationReason(99))) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestExtractOriginalURIFromCrashPage(t *testing.T) { | |||
| 18 | + | assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1")) | |||
| 19 | + | assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash")) | |||
| 20 | + | assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com")) | |||
| 21 | + | assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad")) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestBuildCrashPageURI(t *testing.T) { | |||
| 25 | + | assert.Equal(t, "dumb://home/crash", buildCrashPageURI("")) | |||
| 26 | + | assert.Equal(t, "dumb://home/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2")) | |||
| 27 | + | } | |||
M internal/ui/coordinator/navigation.go +9 -1
| @@ -233,7 +233,15 @@ func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity | |||||
| 233 | return | 233 | return | ||
| 234 | } | 234 | } | ||
| 235 | 235 | ||||
| 236 | - | c.navigateUC.RecordHistory(ctx, url) | 236 | + | c.navigateUC.RecordHistory(ctx, string(paneID), url) |
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // ClearPaneHistory clears per-pane navigation history deduplication state. | |||
| 240 | + | func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) { | |||
| 241 | + | if c.navigateUC == nil { | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | c.navigateUC.ClearPaneHistory(string(paneID)) | |||
| 237 | } | 245 | } | ||
| 238 | 246 | ||||
| 239 | // ActiveWebView returns the WebView for the active pane (for zoom operations). | 247 | // ActiveWebView returns the WebView for the active pane (for zoom operations). | ||
M internal/ui/coordinator/oauth.go +44 -0
| @@ -2,6 +2,8 @@ package coordinator | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strings" | 4 | "strings" | ||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 5 | ) | 7 | ) | ||
| 6 | 8 | ||||
| 7 | // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress. | 9 | // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress. | ||
| @@ -138,3 +140,45 @@ func IsOAuthError(url string) bool { | |||||
| 138 | lower := strings.ToLower(url) | 140 | lower := strings.ToLower(url) | ||
| 139 | return strings.Contains(lower, "error=") | 141 | return strings.Contains(lower, "error=") | ||
| 140 | } | 142 | } | ||
| 143 | + | ||||
| 144 | + | func composeOnClose(existing, next func()) func() { | |||
| 145 | + | if existing == nil { | |||
| 146 | + | return next | |||
| 147 | + | } | |||
| 148 | + | if next == nil { | |||
| 149 | + | return existing | |||
| 150 | + | } | |||
| 151 | + | return func() { | |||
| 152 | + | existing() | |||
| 153 | + | next() | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func composeOnURIChanged(existing, next func(string)) func(string) { | |||
| 158 | + | if existing == nil { | |||
| 159 | + | return next | |||
| 160 | + | } | |||
| 161 | + | if next == nil { | |||
| 162 | + | return existing | |||
| 163 | + | } | |||
| 164 | + | return func(uri string) { | |||
| 165 | + | existing(uri) | |||
| 166 | + | next(uri) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func composeOnLoadChanged( | |||
| 171 | + | existing func(webkit.LoadEvent), | |||
| 172 | + | next func(webkit.LoadEvent), | |||
| 173 | + | ) func(webkit.LoadEvent) { | |||
| 174 | + | if existing == nil { | |||
| 175 | + | return next | |||
| 176 | + | } | |||
| 177 | + | if next == nil { | |||
| 178 | + | return existing | |||
| 179 | + | } | |||
| 180 | + | return func(event webkit.LoadEvent) { | |||
| 181 | + | existing(event) | |||
| 182 | + | next(event) | |||
| 183 | + | } | |||
| 184 | + | } | |||
A internal/ui/coordinator/oauth_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestComposeOnClose_Order(t *testing.T) { | |||
| 16 | + | var calls []string | |||
| 17 | + | ||||
| 18 | + | composed := composeOnClose( | |||
| 19 | + | func() { calls = append(calls, "existing") }, | |||
| 20 | + | func() { calls = append(calls, "next") }, | |||
| 21 | + | ) | |||
| 22 | + | composed() | |||
| 23 | + | ||||
| 24 | + | assert.Equal(t, []string{"existing", "next"}, calls) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestComposeOnURIChanged_Order(t *testing.T) { | |||
| 28 | + | var calls []string | |||
| 29 | + | ||||
| 30 | + | composed := composeOnURIChanged( | |||
| 31 | + | func(_ string) { calls = append(calls, "existing") }, | |||
| 32 | + | func(_ string) { calls = append(calls, "next") }, | |||
| 33 | + | ) | |||
| 34 | + | composed("https://example.com") | |||
| 35 | + | ||||
| 36 | + | assert.Equal(t, []string{"existing", "next"}, calls) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestComposeOnLoadChanged_Order(t *testing.T) { | |||
| 40 | + | var calls []string | |||
| 41 | + | ||||
| 42 | + | composed := composeOnLoadChanged( | |||
| 43 | + | func(_ webkit.LoadEvent) { calls = append(calls, "existing") }, | |||
| 44 | + | func(_ webkit.LoadEvent) { calls = append(calls, "next") }, | |||
| 45 | + | ) | |||
| 46 | + | composed(webkit.LoadCommitted) | |||
| 47 | + | ||||
| 48 | + | assert.Equal(t, []string{"existing", "next"}, calls) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) { | |||
| 52 | + | parentPaneID := entity.PaneID("parent-pane") | |||
| 53 | + | popupID := port.WebViewID(101) | |||
| 54 | + | ||||
| 55 | + | c := &ContentCoordinator{ | |||
| 56 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |||
| 57 | + | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |||
| 58 | + | popupRefresh: make(map[entity.PaneID]*time.Timer), | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | c.trackOAuthPopup(popupID, parentPaneID) | |||
| 62 | + | c.capturePopupOAuthState(popupID, "https://app/callback?code=123") | |||
| 63 | + | c.handlePopupOAuthClose(context.Background(), popupID) | |||
| 64 | + | ||||
| 65 | + | c.popupMu.RLock() | |||
| 66 | + | _, exists := c.popupOAuth[popupID] | |||
| 67 | + | refreshTimer := c.popupRefresh[parentPaneID] | |||
| 68 | + | c.popupMu.RUnlock() | |||
| 69 | + | ||||
| 70 | + | assert.False(t, exists, "oauth state should be removed after close handling") | |||
| 71 | + | assert.NotNil(t, refreshTimer, "successful oauth close should schedule parent refresh") | |||
| 72 | + | ||||
| 73 | + | waitFor(t, time.Second, func() bool { | |||
| 74 | + | c.popupMu.RLock() | |||
| 75 | + | defer c.popupMu.RUnlock() | |||
| 76 | + | return c.popupRefresh[parentPaneID] == nil | |||
| 77 | + | }) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) { | |||
| 81 | + | parentPaneID := entity.PaneID("parent-pane") | |||
| 82 | + | popupID := port.WebViewID(102) | |||
| 83 | + | ||||
| 84 | + | c := &ContentCoordinator{ | |||
| 85 | + | webViews: make(map[entity.PaneID]*webkit.WebView), | |||
| 86 | + | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |||
| 87 | + | popupRefresh: make(map[entity.PaneID]*time.Timer), | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | c.trackOAuthPopup(popupID, parentPaneID) | |||
| 91 | + | c.capturePopupOAuthState(popupID, "https://app/callback?error=access_denied") | |||
| 92 | + | c.handlePopupOAuthClose(context.Background(), popupID) | |||
| 93 | + | ||||
| 94 | + | c.popupMu.RLock() | |||
| 95 | + | defer c.popupMu.RUnlock() | |||
| 96 | + | assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func waitFor(t *testing.T, timeout time.Duration, condition func() bool) { | |||
| 100 | + | t.Helper() | |||
| 101 | + | ||||
| 102 | + | deadline := time.Now().Add(timeout) | |||
| 103 | + | for time.Now().Before(deadline) { | |||
| 104 | + | if condition() { | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | time.Sleep(20 * time.Millisecond) | |||
| 108 | + | } | |||
| 109 | + | t.Fatalf("condition not met before timeout (%s)", timeout) | |||
| 110 | + | } | |||
M internal/ui/coordinator/workspace.go +180 -104
| @@ -35,6 +35,7 @@ type WorkspaceCoordinator struct { | |||||
| 35 | onCloseLastPane func(ctx context.Context) error | 35 | onCloseLastPane func(ctx context.Context) error | ||
| 36 | onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior | 36 | onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior | ||
| 37 | onStateChanged func() // For session snapshots | 37 | onStateChanged func() // For session snapshots | ||
| 38 | + | onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks | |||
| 38 | } | 39 | } | ||
| 39 | 40 | ||||
| 40 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | 41 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | ||
| @@ -64,6 +65,16 @@ type stackPaneContext struct { | |||||
| 64 | originalTitle string | 65 | originalTitle string | ||
| 65 | } | 66 | } | ||
| 66 | 67 | ||||
| 68 | + | type incrementalCloseContext struct { | |||
| 69 | + | parentNode *entity.PaneNode | |||
| 70 | + | siblingNode *entity.PaneNode | |||
| 71 | + | grandparentNode *entity.PaneNode | |||
| 72 | + | parentWidget layout.Widget | |||
| 73 | + | siblingIsStartChild bool | |||
| 74 | + | parentIsStartInGrand bool | |||
| 75 | + | precheckReason string | |||
| 76 | + | } | |||
| 77 | + | ||||
| 67 | // NewWorkspaceCoordinator creates a new WorkspaceCoordinator. | 78 | // NewWorkspaceCoordinator creates a new WorkspaceCoordinator. | ||
| 68 | func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator { | 79 | func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator { | ||
| 69 | log := logging.FromContext(ctx) | 80 | log := logging.FromContext(ctx) | ||
| @@ -90,6 +101,11 @@ func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) { | |||||
| 90 | c.onStateChanged = fn | 101 | c.onStateChanged = fn | ||
| 91 | } | 102 | } | ||
| 92 | 103 | ||||
| 104 | + | // SetOnPaneClosed sets a callback invoked after a pane is successfully closed. | |||
| 105 | + | func (c *WorkspaceCoordinator) SetOnPaneClosed(fn func(paneID entity.PaneID)) { | |||
| 106 | + | c.onPaneClosed = fn | |||
| 107 | + | } | |||
| 108 | + | ||||
| 93 | // notifyStateChanged triggers the state changed callback if set. | 109 | // notifyStateChanged triggers the state changed callback if set. | ||
| 94 | func (c *WorkspaceCoordinator) notifyStateChanged() { | 110 | func (c *WorkspaceCoordinator) notifyStateChanged() { | ||
| 95 | if c.onStateChanged != nil { | 111 | if c.onStateChanged != nil { | ||
| @@ -717,52 +733,8 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||||
| 717 | return nil | 733 | return nil | ||
| 718 | } | 734 | } | ||
| 719 | 735 | ||||
| 720 | - | // BEFORE domain changes: capture widget info for incremental close | 736 | + | // BEFORE domain changes: capture incremental close context. |
| 721 | - | var parentNode *entity.PaneNode | 737 | + | closeCtx := c.captureIncrementalCloseContext(wsView, activePane) |
| 722 | - | var siblingNode *entity.PaneNode | |||
| 723 | - | var grandparentNode *entity.PaneNode | |||
| 724 | - | var parentWidget layout.Widget | |||
| 725 | - | var siblingIsStartChild bool // true if sibling is the start (left/top) child in parent | |||
| 726 | - | var parentIsStartInGrand bool // true if parent is the start (left/top) child in grandparent | |||
| 727 | - | ||||
| 728 | - | if wsView != nil { | |||
| 729 | - | tr := wsView.TreeRenderer() | |||
| 730 | - | if tr != nil && activePane.Parent != nil { | |||
| 731 | - | parentNode = activePane.Parent | |||
| 732 | - | ||||
| 733 | - | // Find sibling (the other child of parent) | |||
| 734 | - | // If closing pane is start child, sibling is end child (and vice versa) | |||
| 735 | - | if parentNode.Left() == activePane { | |||
| 736 | - | siblingNode = parentNode.Right() | |||
| 737 | - | siblingIsStartChild = false // sibling is end/right child | |||
| 738 | - | } else { | |||
| 739 | - | siblingNode = parentNode.Left() | |||
| 740 | - | siblingIsStartChild = true // sibling is start/left child | |||
| 741 | - | } | |||
| 742 | - | ||||
| 743 | - | grandparentNode = parentNode.Parent | |||
| 744 | - | parentWidget = tr.Lookup(parentNode.ID) | |||
| 745 | - | ||||
| 746 | - | // Determine parent's position in grandparent (if grandparent exists) | |||
| 747 | - | if grandparentNode != nil { | |||
| 748 | - | parentIsStartInGrand = grandparentNode.Left() == parentNode | |||
| 749 | - | } | |||
| 750 | - | ||||
| 751 | - | log.Debug(). | |||
| 752 | - | Str("parent_id", parentNode.ID). | |||
| 753 | - | Str("sibling_id", func() string { | |||
| 754 | - | if siblingNode != nil { | |||
| 755 | - | return siblingNode.ID | |||
| 756 | - | } | |||
| 757 | - | return nilString | |||
| 758 | - | }()). | |||
| 759 | - | Bool("sibling_is_start", siblingIsStartChild). | |||
| 760 | - | Bool("parent_is_start_in_grand", parentIsStartInGrand). | |||
| 761 | - | Bool("has_grandparent", grandparentNode != nil). | |||
| 762 | - | Bool("has_parent_widget", parentWidget != nil). | |||
| 763 | - | Msg("captured close context") | |||
| 764 | - | } | |||
| 765 | - | } | |||
| 766 | 738 | ||||
| 767 | // Now do domain changes | 739 | // Now do domain changes | ||
| 768 | _, err := c.panesUC.Close(ctx, ws, activePane) | 740 | _, err := c.panesUC.Close(ctx, ws, activePane) | ||
| @@ -776,13 +748,11 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||||
| 776 | wsView, | 748 | wsView, | ||
| 777 | ws, | 749 | ws, | ||
| 778 | closingPaneID, | 750 | closingPaneID, | ||
| 779 | - | parentNode, | 751 | + | closeCtx, |
| 780 | - | siblingNode, | |||
| 781 | - | grandparentNode, | |||
| 782 | - | parentWidget, | |||
| 783 | - | siblingIsStartChild, | |||
| 784 | - | parentIsStartInGrand, | |||
| 785 | ) | 752 | ) | ||
| 753 | + | if c.onPaneClosed != nil { | |||
| 754 | + | c.onPaneClosed(closingPaneID) | |||
| 755 | + | } | |||
| 786 | 756 | ||||
| 787 | // Notify state change for session snapshots | 757 | // Notify state change for session snapshots | ||
| 788 | c.notifyStateChanged() | 758 | c.notifyStateChanged() | ||
| @@ -824,35 +794,8 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity. | |||||
| 824 | return nil | 794 | return nil | ||
| 825 | } | 795 | } | ||
| 826 | 796 | ||||
| 827 | - | // BEFORE domain changes: capture widget info for incremental close | 797 | + | // BEFORE domain changes: capture incremental close context. |
| 828 | - | var parentNode *entity.PaneNode | 798 | + | closeCtx := c.captureIncrementalCloseContext(wsView, paneNode) |
| 829 | - | var siblingNode *entity.PaneNode | |||
| 830 | - | var grandparentNode *entity.PaneNode | |||
| 831 | - | var parentWidget layout.Widget | |||
| 832 | - | var siblingIsStartChild bool | |||
| 833 | - | var parentIsStartInGrand bool | |||
| 834 | - | ||||
| 835 | - | if wsView != nil { | |||
| 836 | - | tr := wsView.TreeRenderer() | |||
| 837 | - | if tr != nil && paneNode.Parent != nil { | |||
| 838 | - | parentNode = paneNode.Parent | |||
| 839 | - | ||||
| 840 | - | if parentNode.Left() == paneNode { | |||
| 841 | - | siblingNode = parentNode.Right() | |||
| 842 | - | siblingIsStartChild = false | |||
| 843 | - | } else { | |||
| 844 | - | siblingNode = parentNode.Left() | |||
| 845 | - | siblingIsStartChild = true | |||
| 846 | - | } | |||
| 847 | - | ||||
| 848 | - | grandparentNode = parentNode.Parent | |||
| 849 | - | parentWidget = tr.Lookup(parentNode.ID) | |||
| 850 | - | ||||
| 851 | - | if grandparentNode != nil { | |||
| 852 | - | parentIsStartInGrand = grandparentNode.Left() == parentNode | |||
| 853 | - | } | |||
| 854 | - | } | |||
| 855 | - | } | |||
| 856 | 799 | ||||
| 857 | // Now do domain changes | 800 | // Now do domain changes | ||
| 858 | _, err := c.panesUC.Close(ctx, ws, paneNode) | 801 | _, err := c.panesUC.Close(ctx, ws, paneNode) | ||
| @@ -866,13 +809,11 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity. | |||||
| 866 | wsView, | 809 | wsView, | ||
| 867 | ws, | 810 | ws, | ||
| 868 | paneID, | 811 | paneID, | ||
| 869 | - | parentNode, | 812 | + | closeCtx, |
| 870 | - | siblingNode, | |||
| 871 | - | grandparentNode, | |||
| 872 | - | parentWidget, | |||
| 873 | - | siblingIsStartChild, | |||
| 874 | - | parentIsStartInGrand, | |||
| 875 | ) | 813 | ) | ||
| 814 | + | if c.onPaneClosed != nil { | |||
| 815 | + | c.onPaneClosed(paneID) | |||
| 816 | + | } | |||
| 876 | 817 | ||||
| 877 | // Notify state change for session snapshots | 818 | // Notify state change for session snapshots | ||
| 878 | c.notifyStateChanged() | 819 | c.notifyStateChanged() | ||
| @@ -894,6 +835,16 @@ func (c *WorkspaceCoordinator) doIncrementalClose( | |||||
| 894 | parentIsStartInGrand bool, // true if parent is start/left child in grandparent | 835 | parentIsStartInGrand bool, // true if parent is start/left child in grandparent | ||
| 895 | ) error { | 836 | ) error { | ||
| 896 | log := logging.FromContext(ctx) | 837 | log := logging.FromContext(ctx) | ||
| 838 | + | if parentNode == nil { | |||
| 839 | + | return fmt.Errorf("parent node missing") | |||
| 840 | + | } | |||
| 841 | + | if siblingNode == nil { | |||
| 842 | + | return fmt.Errorf("sibling node missing") | |||
| 843 | + | } | |||
| 844 | + | if parentWidget == nil { | |||
| 845 | + | return fmt.Errorf("parent widget missing") | |||
| 846 | + | } | |||
| 847 | + | ||||
| 897 | tr := wsView.TreeRenderer() | 848 | tr := wsView.TreeRenderer() | ||
| 898 | if tr == nil { | 849 | if tr == nil { | ||
| 899 | return fmt.Errorf("tree renderer not available") | 850 | return fmt.Errorf("tree renderer not available") | ||
| @@ -1015,12 +966,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||||
| 1015 | wsView *component.WorkspaceView, | 966 | wsView *component.WorkspaceView, | ||
| 1016 | ws *entity.Workspace, | 967 | ws *entity.Workspace, | ||
| 1017 | paneID entity.PaneID, | 968 | paneID entity.PaneID, | ||
| 1018 | - | parentNode *entity.PaneNode, | 969 | + | closeCtx incrementalCloseContext, |
| 1019 | - | siblingNode *entity.PaneNode, | |||
| 1020 | - | grandparentNode *entity.PaneNode, | |||
| 1021 | - | parentWidget layout.Widget, | |||
| 1022 | - | siblingIsStartChild bool, | |||
| 1023 | - | parentIsStartInGrand bool, | |||
| 1024 | ) { | 970 | ) { | ||
| 1025 | if wsView == nil { | 971 | if wsView == nil { | ||
| 1026 | return | 972 | return | ||
| @@ -1028,29 +974,40 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||||
| 1028 | 974 | ||||
| 1029 | log := logging.FromContext(ctx) | 975 | log := logging.FromContext(ctx) | ||
| 1030 | 976 | ||||
| 1031 | - | if parentNode != nil { | 977 | + | if closeCtx.parentNode != nil { |
| 1032 | var closeErr error | 978 | var closeErr error | ||
| 1033 | 979 | ||||
| 1034 | - | if parentNode.IsStacked { | 980 | + | if closeCtx.parentNode.IsStacked { |
| 1035 | - | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode) | 981 | + | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, closeCtx.parentNode) |
| 1036 | - | } else if siblingNode != nil && parentWidget != nil { | 982 | + | } else if closeCtx.precheckReason != "" { |
| 983 | + | closeErr = fmt.Errorf("incremental close precheck failed: %s", closeCtx.precheckReason) | |||
| 984 | + | } else if closeCtx.siblingNode != nil && closeCtx.parentWidget != nil { | |||
| 1037 | closeErr = c.doIncrementalClose( | 985 | closeErr = c.doIncrementalClose( | ||
| 1038 | ctx, | 986 | ctx, | ||
| 1039 | wsView, | 987 | wsView, | ||
| 1040 | paneID, | 988 | paneID, | ||
| 1041 | - | siblingNode, | 989 | + | closeCtx.siblingNode, |
| 1042 | - | parentNode, | 990 | + | closeCtx.parentNode, |
| 1043 | - | grandparentNode, | 991 | + | closeCtx.grandparentNode, |
| 1044 | - | parentWidget, | 992 | + | closeCtx.parentWidget, |
| 1045 | - | siblingIsStartChild, | 993 | + | closeCtx.siblingIsStartChild, |
| 1046 | - | parentIsStartInGrand, | 994 | + | closeCtx.parentIsStartInGrand, |
| 1047 | ) | 995 | ) | ||
| 1048 | } else { | 996 | } else { | ||
| 1049 | closeErr = fmt.Errorf("missing context for incremental close") | 997 | closeErr = fmt.Errorf("missing context for incremental close") | ||
| 1050 | } | 998 | } | ||
| 1051 | 999 | ||||
| 1052 | if closeErr != nil { | 1000 | if closeErr != nil { | ||
| 1053 | - | log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild") | 1001 | + | fallbackLog := log.Warn().Err(closeErr). |
| 1002 | + | Str("pane_id", string(paneID)). | |||
| 1003 | + | Str("parent_id", paneNodeID(closeCtx.parentNode)). | |||
| 1004 | + | Str("sibling_id", paneNodeID(closeCtx.siblingNode)). | |||
| 1005 | + | Str("grandparent_id", paneNodeID(closeCtx.grandparentNode)). | |||
| 1006 | + | Bool("has_parent_widget", closeCtx.parentWidget != nil) | |||
| 1007 | + | if closeCtx.precheckReason != "" { | |||
| 1008 | + | fallbackLog = fallbackLog.Str("precheck_reason", closeCtx.precheckReason) | |||
| 1009 | + | } | |||
| 1010 | + | fallbackLog.Msg("incremental close failed, falling back to rebuild") | |||
| 1054 | if err := wsView.Rebuild(ctx); err != nil { | 1011 | if err := wsView.Rebuild(ctx); err != nil { | ||
| 1055 | log.Error().Err(err).Msg("failed to rebuild workspace view") | 1012 | log.Error().Err(err).Msg("failed to rebuild workspace view") | ||
| 1056 | } | 1013 | } | ||
| @@ -1073,6 +1030,113 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||||
| 1073 | wsView.FocusPane(ws.ActivePaneID) | 1030 | wsView.FocusPane(ws.ActivePaneID) | ||
| 1074 | } | 1031 | } | ||
| 1075 | 1032 | ||||
| 1033 | + | func (c *WorkspaceCoordinator) captureIncrementalCloseContext( | |||
| 1034 | + | wsView *component.WorkspaceView, | |||
| 1035 | + | closingPane *entity.PaneNode, | |||
| 1036 | + | ) incrementalCloseContext { | |||
| 1037 | + | closeCtx, err := deriveIncrementalCloseTreeContext(closingPane) | |||
| 1038 | + | if err != nil { | |||
| 1039 | + | closeCtx.precheckReason = err.Error() | |||
| 1040 | + | return closeCtx | |||
| 1041 | + | } | |||
| 1042 | + | ||||
| 1043 | + | if closeCtx.parentNode == nil || closeCtx.parentNode.IsStacked { | |||
| 1044 | + | return closeCtx | |||
| 1045 | + | } | |||
| 1046 | + | ||||
| 1047 | + | if wsView == nil { | |||
| 1048 | + | closeCtx.precheckReason = "workspace view unavailable" | |||
| 1049 | + | return closeCtx | |||
| 1050 | + | } | |||
| 1051 | + | ||||
| 1052 | + | tr := wsView.TreeRenderer() | |||
| 1053 | + | if tr == nil { | |||
| 1054 | + | closeCtx.precheckReason = "tree renderer unavailable" | |||
| 1055 | + | return closeCtx | |||
| 1056 | + | } | |||
| 1057 | + | ||||
| 1058 | + | closeCtx.parentWidget = tr.Lookup(closeCtx.parentNode.ID) | |||
| 1059 | + | if closeCtx.parentWidget == nil { | |||
| 1060 | + | closeCtx.precheckReason = "parent widget missing" | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | return closeCtx | |||
| 1064 | + | } | |||
| 1065 | + | ||||
| 1066 | + | func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) { | |||
| 1067 | + | var closeCtx incrementalCloseContext | |||
| 1068 | + | if closingPane == nil { | |||
| 1069 | + | return closeCtx, errors.New("closing pane missing") | |||
| 1070 | + | } | |||
| 1071 | + | ||||
| 1072 | + | parentNode := closingPane.Parent | |||
| 1073 | + | if parentNode == nil { | |||
| 1074 | + | return closeCtx, errors.New("closing pane has no parent") | |||
| 1075 | + | } | |||
| 1076 | + | ||||
| 1077 | + | closeCtx.parentNode = parentNode | |||
| 1078 | + | closeCtx.grandparentNode = parentNode.Parent | |||
| 1079 | + | ||||
| 1080 | + | if parentNode.IsStacked { | |||
| 1081 | + | return closeCtx, nil | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | if !parentNode.IsSplit() { | |||
| 1085 | + | return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID) | |||
| 1086 | + | } | |||
| 1087 | + | ||||
| 1088 | + | if len(parentNode.Children) != 2 { | |||
| 1089 | + | return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children)) | |||
| 1090 | + | } | |||
| 1091 | + | ||||
| 1092 | + | leftChild := parentNode.Left() | |||
| 1093 | + | rightChild := parentNode.Right() | |||
| 1094 | + | if leftChild == closingPane && rightChild == nil { | |||
| 1095 | + | return closeCtx, errors.New("sibling missing") | |||
| 1096 | + | } | |||
| 1097 | + | if rightChild == closingPane && leftChild == nil { | |||
| 1098 | + | return closeCtx, errors.New("sibling missing") | |||
| 1099 | + | } | |||
| 1100 | + | if leftChild == nil || rightChild == nil { | |||
| 1101 | + | return closeCtx, errors.New("split parent has nil child") | |||
| 1102 | + | } | |||
| 1103 | + | ||||
| 1104 | + | switch { | |||
| 1105 | + | case leftChild == closingPane: | |||
| 1106 | + | closeCtx.siblingNode = rightChild | |||
| 1107 | + | closeCtx.siblingIsStartChild = false | |||
| 1108 | + | case rightChild == closingPane: | |||
| 1109 | + | closeCtx.siblingNode = leftChild | |||
| 1110 | + | closeCtx.siblingIsStartChild = true | |||
| 1111 | + | default: | |||
| 1112 | + | return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID) | |||
| 1113 | + | } | |||
| 1114 | + | ||||
| 1115 | + | if closeCtx.siblingNode == nil { | |||
| 1116 | + | return closeCtx, errors.New("sibling missing") | |||
| 1117 | + | } | |||
| 1118 | + | ||||
| 1119 | + | if closeCtx.grandparentNode != nil { | |||
| 1120 | + | switch { | |||
| 1121 | + | case closeCtx.grandparentNode.Left() == parentNode: | |||
| 1122 | + | closeCtx.parentIsStartInGrand = true | |||
| 1123 | + | case closeCtx.grandparentNode.Right() == parentNode: | |||
| 1124 | + | closeCtx.parentIsStartInGrand = false | |||
| 1125 | + | default: | |||
| 1126 | + | return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID) | |||
| 1127 | + | } | |||
| 1128 | + | } | |||
| 1129 | + | ||||
| 1130 | + | return closeCtx, nil | |||
| 1131 | + | } | |||
| 1132 | + | ||||
| 1133 | + | func paneNodeID(node *entity.PaneNode) string { | |||
| 1134 | + | if node == nil { | |||
| 1135 | + | return nilString | |||
| 1136 | + | } | |||
| 1137 | + | return node.ID | |||
| 1138 | + | } | |||
| 1139 | + | ||||
| 1076 | // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree. | 1140 | // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree. | ||
| 1077 | func (c *WorkspaceCoordinator) doIncrementalStackClose( | 1141 | func (c *WorkspaceCoordinator) doIncrementalStackClose( | ||
| 1078 | ctx context.Context, | 1142 | ctx context.Context, | ||
| @@ -1559,6 +1623,18 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||||
| 1559 | } | 1623 | } | ||
| 1560 | clickedPaneID := clickedChild.Pane.ID | 1624 | clickedPaneID := clickedChild.Pane.ID | ||
| 1561 | 1625 | ||||
| 1626 | + | // Sync the incoming pane's title bar with its current WebView title | |||
| 1627 | + | // to avoid showing a stale title after navigation occurred while hidden. | |||
| 1628 | + | incomingTitle := c.contentCoord.GetTitle(clickedPaneID) | |||
| 1629 | + | if incomingTitle == "" { | |||
| 1630 | + | incomingTitle = clickedChild.Pane.Title | |||
| 1631 | + | } | |||
| 1632 | + | if incomingTitle != "" { | |||
| 1633 | + | if err := sv.UpdateTitle(clickedIndex, incomingTitle); err != nil { | |||
| 1634 | + | log.Warn().Err(err).Msg("failed to update incoming pane title") | |||
| 1635 | + | } | |||
| 1636 | + | } | |||
| 1637 | + | ||||
| 1562 | // Update StackedView active index | 1638 | // Update StackedView active index | ||
| 1563 | if err := sv.SetActive(ctx, clickedIndex); err != nil { | 1639 | if err := sv.SetActive(ctx, clickedIndex); err != nil { | ||
| 1564 | log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack") | 1640 | log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack") | ||
A internal/ui/coordinator/workspace_close_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func testLeafNode(id string) *entity.PaneNode { | |||
| 12 | + | return &entity.PaneNode{ | |||
| 13 | + | ID: id, | |||
| 14 | + | Pane: entity.NewPane(entity.PaneID(id)), | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func testSplitNode(id string, left, right *entity.PaneNode) *entity.PaneNode { | |||
| 19 | + | node := &entity.PaneNode{ | |||
| 20 | + | ID: id, | |||
| 21 | + | SplitDir: entity.SplitHorizontal, | |||
| 22 | + | Children: []*entity.PaneNode{left, right}, | |||
| 23 | + | } | |||
| 24 | + | if left != nil { | |||
| 25 | + | left.Parent = node | |||
| 26 | + | } | |||
| 27 | + | if right != nil { | |||
| 28 | + | right.Parent = node | |||
| 29 | + | } | |||
| 30 | + | return node | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestDeriveIncrementalCloseTreeContext_ValidSplit(t *testing.T) { | |||
| 34 | + | closing := testLeafNode("closing") | |||
| 35 | + | sibling := testLeafNode("sibling") | |||
| 36 | + | parent := testSplitNode("parent", closing, sibling) | |||
| 37 | + | other := testLeafNode("other") | |||
| 38 | + | grand := testSplitNode("grand", parent, other) | |||
| 39 | + | ||||
| 40 | + | ctx, err := deriveIncrementalCloseTreeContext(closing) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("unexpected error: %v", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | if ctx.parentNode != parent { | |||
| 46 | + | t.Fatalf("parent mismatch") | |||
| 47 | + | } | |||
| 48 | + | if ctx.siblingNode != sibling { | |||
| 49 | + | t.Fatalf("sibling mismatch") | |||
| 50 | + | } | |||
| 51 | + | if ctx.grandparentNode != grand { | |||
| 52 | + | t.Fatalf("grandparent mismatch") | |||
| 53 | + | } | |||
| 54 | + | if ctx.siblingIsStartChild { | |||
| 55 | + | t.Fatalf("sibling should be end child") | |||
| 56 | + | } | |||
| 57 | + | if !ctx.parentIsStartInGrand { | |||
| 58 | + | t.Fatalf("parent should be start child in grandparent") | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) { | |||
| 63 | + | closing := testLeafNode("closing") | |||
| 64 | + | parent := testSplitNode("parent", closing, nil) | |||
| 65 | + | ||||
| 66 | + | _, err := deriveIncrementalCloseTreeContext(closing) | |||
| 67 | + | if err == nil { | |||
| 68 | + | t.Fatalf("expected invariant error") | |||
| 69 | + | } | |||
| 70 | + | if !strings.Contains(err.Error(), "sibling") && !strings.Contains(err.Error(), "nil child") { | |||
| 71 | + | t.Fatalf("expected sibling/nil-child error, got: %v", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if got := paneNodeID(parent); got != "parent" { | |||
| 75 | + | t.Fatalf("unexpected parent id helper output: %s", got) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestDeriveIncrementalCloseTreeContext_ParentDoesNotContainClosingPane(t *testing.T) { | |||
| 80 | + | closing := testLeafNode("closing") | |||
| 81 | + | left := testLeafNode("left") | |||
| 82 | + | right := testLeafNode("right") | |||
| 83 | + | parent := testSplitNode("parent", left, right) | |||
| 84 | + | closing.Parent = parent | |||
| 85 | + | ||||
| 86 | + | _, err := deriveIncrementalCloseTreeContext(closing) | |||
| 87 | + | if err == nil { | |||
| 88 | + | t.Fatalf("expected invariant error") | |||
| 89 | + | } | |||
| 90 | + | if !strings.Contains(err.Error(), "not found under parent") { | |||
| 91 | + | t.Fatalf("unexpected error: %v", err) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) { | |||
| 96 | + | closing := testLeafNode("closing") | |||
| 97 | + | testSplitNode("parent", closing, nil) | |||
| 98 | + | ||||
| 99 | + | coord := &WorkspaceCoordinator{} | |||
| 100 | + | ctx := coord.captureIncrementalCloseContext(nil, closing) | |||
| 101 | + | if ctx.precheckReason == "" { | |||
| 102 | + | t.Fatalf("expected precheck reason") | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestDeriveIncrementalCloseTreeContext_ConcurrentPaneAndTabCloseSnapshots(t *testing.T) { | |||
| 107 | + | paneCloseNode := testLeafNode("pane-close") | |||
| 108 | + | paneCloseSibling := testLeafNode("pane-close-sibling") | |||
| 109 | + | testSplitNode("pane-parent", paneCloseNode, paneCloseSibling) | |||
| 110 | + | ||||
| 111 | + | tabCloseNode := testLeafNode("tab-close") | |||
| 112 | + | tabCloseSibling := testLeafNode("tab-close-sibling") | |||
| 113 | + | testSplitNode("tab-parent", tabCloseSibling, tabCloseNode) | |||
| 114 | + | ||||
| 115 | + | run := func(node *entity.PaneNode, wg *sync.WaitGroup, errCh chan<- error) { | |||
| 116 | + | defer wg.Done() | |||
| 117 | + | for i := 0; i < 200; i++ { | |||
| 118 | + | if _, err := deriveIncrementalCloseTreeContext(node); err != nil { | |||
| 119 | + | errCh <- err | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | errCh := make(chan error, 2) | |||
| 126 | + | var wg sync.WaitGroup | |||
| 127 | + | wg.Add(2) | |||
| 128 | + | go run(paneCloseNode, &wg, errCh) | |||
| 129 | + | go run(tabCloseNode, &wg, errCh) | |||
| 130 | + | wg.Wait() | |||
| 131 | + | close(errCh) | |||
| 132 | + | ||||
| 133 | + | for err := range errCh { | |||
| 134 | + | if err != nil { | |||
| 135 | + | t.Fatalf("unexpected concurrent derive error: %v", err) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | } | |||
M internal/ui/input/hover.go +25 -2
| @@ -18,16 +18,21 @@ const ( | |||||
| 18 | // HoverCallback is called when a pane should receive focus from hover. | 18 | // HoverCallback is called when a pane should receive focus from hover. | ||
| 19 | type HoverCallback func(paneID entity.PaneID) | 19 | type HoverCallback func(paneID entity.PaneID) | ||
| 20 | 20 | ||||
| 21 | + | // MotionCallback is called when intentional mouse movement is detected. | |||
| 22 | + | type MotionCallback func() | |||
| 23 | + | ||||
| 21 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | 24 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | ||
| 22 | // It uses a debounce timer to avoid rapid focus switches. | 25 | // It uses a debounce timer to avoid rapid focus switches. | ||
| 23 | type HoverHandler struct { | 26 | type HoverHandler struct { | ||
| 24 | motionCtrl *gtk.EventControllerMotion | 27 | motionCtrl *gtk.EventControllerMotion | ||
| 25 | paneID entity.PaneID | 28 | paneID entity.PaneID | ||
| 26 | onEnter HoverCallback | 29 | onEnter HoverCallback | ||
| 30 | + | onMotion MotionCallback | |||
| 27 | 31 | ||||
| 28 | // Callback retention: must stay reachable by Go GC. | 32 | // Callback retention: must stay reachable by Go GC. | ||
| 29 | - | enterCb func(gtk.EventControllerMotion, float64, float64) | 33 | + | enterCb func(gtk.EventControllerMotion, float64, float64) |
| 30 | - | leaveCb func(gtk.EventControllerMotion) | 34 | + | leaveCb func(gtk.EventControllerMotion) |
| 35 | + | motionCb func(gtk.EventControllerMotion, float64, float64) | |||
| 31 | 36 | ||||
| 32 | timer *time.Timer | 37 | timer *time.Timer | ||
| 33 | timerMu sync.Mutex | 38 | timerMu sync.Mutex | ||
| @@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) { | |||||
| 52 | h.onEnter = fn | 57 | h.onEnter = fn | ||
| 53 | } | 58 | } | ||
| 54 | 59 | ||||
| 60 | + | // SetOnMotion sets the callback for intentional mouse movement. | |||
| 61 | + | // This fires on actual mouse motion within the pane, not on synthetic | |||
| 62 | + | // enter/leave events from GTK widget rearrangement. | |||
| 63 | + | func (h *HoverHandler) SetOnMotion(fn MotionCallback) { | |||
| 64 | + | h.onMotion = fn | |||
| 65 | + | } | |||
| 66 | + | ||||
| 55 | // AttachTo attaches the hover handler to a GTK widget. | 67 | // AttachTo attaches the hover handler to a GTK widget. | ||
| 56 | func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | 68 | func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | ||
| 57 | log := logging.FromContext(h.ctx) | 69 | log := logging.FromContext(h.ctx) | ||
| @@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 79 | } | 91 | } | ||
| 80 | h.motionCtrl.ConnectLeave(&h.leaveCb) | 92 | h.motionCtrl.ConnectLeave(&h.leaveCb) | ||
| 81 | 93 | ||||
| 94 | + | // Connect motion handler to detect intentional mouse movement. | |||
| 95 | + | // Unlike enter/leave, motion only fires when the cursor physically moves | |||
| 96 | + | // inside the widget — not on synthetic events from widget rearrangement. | |||
| 97 | + | h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |||
| 98 | + | if h.onMotion != nil { | |||
| 99 | + | h.onMotion() | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | h.motionCtrl.ConnectMotion(&h.motionCb) | |||
| 103 | + | ||||
| 82 | // Add controller to widget | 104 | // Add controller to widget | ||
| 83 | widget.AddController(&h.motionCtrl.EventController) | 105 | widget.AddController(&h.motionCtrl.EventController) | ||
| 84 | 106 | ||||
| @@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() { | |||||
| 143 | h.motionCtrl = nil | 165 | h.motionCtrl = nil | ||
| 144 | h.enterCb = nil | 166 | h.enterCb = nil | ||
| 145 | h.leaveCb = nil | 167 | h.leaveCb = nil | ||
| 168 | + | h.motionCb = nil | |||
| 146 | } | 169 | } | ||
M internal/ui/layout/gtk_widgets.go +3 -3
| @@ -159,14 +159,14 @@ func (p *gtkPaned) GetShrinkEndChild() bool { return p.inner.GetShrinkEndChil | |||||
| 159 | func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) } | 159 | func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) } | ||
| 160 | func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() } | 160 | func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() } | ||
| 161 | 161 | ||||
| 162 | - | func (p *gtkPaned) ConnectMap(callback func()) uint32 { | 162 | + | func (p *gtkPaned) ConnectMap(callback func()) uint { |
| 163 | cb := func(_ gtk.Widget) { | 163 | cb := func(_ gtk.Widget) { | ||
| 164 | callback() | 164 | callback() | ||
| 165 | } | 165 | } | ||
| 166 | return p.inner.ConnectMap(&cb) | 166 | return p.inner.ConnectMap(&cb) | ||
| 167 | } | 167 | } | ||
| 168 | 168 | ||||
| 169 | - | func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 { | 169 | + | func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint { |
| 170 | cb := func(_ gobject.Object, _ uintptr) { | 170 | cb := func(_ gobject.Object, _ uintptr) { | ||
| 171 | callback() | 171 | callback() | ||
| 172 | } | 172 | } | ||
| @@ -539,7 +539,7 @@ func (btn *gtkButton) GetChild() Widget { | |||||
| 539 | return >kWidget{inner: child} | 539 | return >kWidget{inner: child} | ||
| 540 | } | 540 | } | ||
| 541 | 541 | ||||
| 542 | - | func (btn *gtkButton) ConnectClicked(callback func()) uint32 { | 542 | + | func (btn *gtkButton) ConnectClicked(callback func()) uint { |
| 543 | cb := func(_ gtk.Button) { | 543 | cb := func(_ gtk.Button) { | ||
| 544 | callback() | 544 | callback() | ||
| 545 | } | 545 | } | ||
M internal/ui/layout/mocks/mocks.go +18 -18
| @@ -1485,18 +1485,18 @@ func (_c *MockPanedWidget_ComputePoint_Call) RunAndReturn(run func(target layout | |||||
| 1485 | } | 1485 | } | ||
| 1486 | 1486 | ||||
| 1487 | // ConnectMap provides a mock function for the type MockPanedWidget | 1487 | // ConnectMap provides a mock function for the type MockPanedWidget | ||
| 1488 | - | func (_mock *MockPanedWidget) ConnectMap(callback func()) uint32 { | 1488 | + | func (_mock *MockPanedWidget) ConnectMap(callback func()) uint { |
| 1489 | ret := _mock.Called(callback) | 1489 | ret := _mock.Called(callback) | ||
| 1490 | 1490 | ||||
| 1491 | if len(ret) == 0 { | 1491 | if len(ret) == 0 { | ||
| 1492 | panic("no return value specified for ConnectMap") | 1492 | panic("no return value specified for ConnectMap") | ||
| 1493 | } | 1493 | } | ||
| 1494 | 1494 | ||||
| 1495 | - | var r0 uint32 | 1495 | + | var r0 uint |
| 1496 | - | if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok { | 1496 | + | if returnFunc, ok := ret.Get(0).(func(func()) uint); ok { |
| 1497 | r0 = returnFunc(callback) | 1497 | r0 = returnFunc(callback) | ||
| 1498 | } else { | 1498 | } else { | ||
| 1499 | - | r0 = ret.Get(0).(uint32) | 1499 | + | r0 = ret.Get(0).(uint) |
| 1500 | } | 1500 | } | ||
| 1501 | return r0 | 1501 | return r0 | ||
| 1502 | } | 1502 | } | ||
| @@ -1525,29 +1525,29 @@ func (_c *MockPanedWidget_ConnectMap_Call) Run(run func(callback func())) *MockP | |||||
| 1525 | return _c | 1525 | return _c | ||
| 1526 | } | 1526 | } | ||
| 1527 | 1527 | ||||
| 1528 | - | func (_c *MockPanedWidget_ConnectMap_Call) Return(v uint32) *MockPanedWidget_ConnectMap_Call { | 1528 | + | func (_c *MockPanedWidget_ConnectMap_Call) Return(v uint) *MockPanedWidget_ConnectMap_Call { |
| 1529 | _c.Call.Return(v) | 1529 | _c.Call.Return(v) | ||
| 1530 | return _c | 1530 | return _c | ||
| 1531 | } | 1531 | } | ||
| 1532 | 1532 | ||||
| 1533 | - | func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectMap_Call { | 1533 | + | func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()) uint) *MockPanedWidget_ConnectMap_Call { |
| 1534 | _c.Call.Return(run) | 1534 | _c.Call.Return(run) | ||
| 1535 | return _c | 1535 | return _c | ||
| 1536 | } | 1536 | } | ||
| 1537 | 1537 | ||||
| 1538 | // ConnectNotifyPosition provides a mock function for the type MockPanedWidget | 1538 | // ConnectNotifyPosition provides a mock function for the type MockPanedWidget | ||
| 1539 | - | func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint32 { | 1539 | + | func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint { |
| 1540 | ret := _mock.Called(callback) | 1540 | ret := _mock.Called(callback) | ||
| 1541 | 1541 | ||||
| 1542 | if len(ret) == 0 { | 1542 | if len(ret) == 0 { | ||
| 1543 | panic("no return value specified for ConnectNotifyPosition") | 1543 | panic("no return value specified for ConnectNotifyPosition") | ||
| 1544 | } | 1544 | } | ||
| 1545 | 1545 | ||||
| 1546 | - | var r0 uint32 | 1546 | + | var r0 uint |
| 1547 | - | if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok { | 1547 | + | if returnFunc, ok := ret.Get(0).(func(func()) uint); ok { |
| 1548 | r0 = returnFunc(callback) | 1548 | r0 = returnFunc(callback) | ||
| 1549 | } else { | 1549 | } else { | ||
| 1550 | - | r0 = ret.Get(0).(uint32) | 1550 | + | r0 = ret.Get(0).(uint) |
| 1551 | } | 1551 | } | ||
| 1552 | return r0 | 1552 | return r0 | ||
| 1553 | } | 1553 | } | ||
| @@ -1576,12 +1576,12 @@ func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func | |||||
| 1576 | return _c | 1576 | return _c | ||
| 1577 | } | 1577 | } | ||
| 1578 | 1578 | ||||
| 1579 | - | func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call { | 1579 | + | func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint) *MockPanedWidget_ConnectNotifyPosition_Call { |
| 1580 | _c.Call.Return(v) | 1580 | _c.Call.Return(v) | ||
| 1581 | return _c | 1581 | return _c | ||
| 1582 | } | 1582 | } | ||
| 1583 | 1583 | ||||
| 1584 | - | func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectNotifyPosition_Call { | 1584 | + | func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint) *MockPanedWidget_ConnectNotifyPosition_Call { |
| 1585 | _c.Call.Return(run) | 1585 | _c.Call.Return(run) | ||
| 1586 | return _c | 1586 | return _c | ||
| 1587 | } | 1587 | } | ||
| @@ -8007,18 +8007,18 @@ func (_c *MockButtonWidget_ComputePoint_Call) RunAndReturn(run func(target layou | |||||
| 8007 | } | 8007 | } | ||
| 8008 | 8008 | ||||
| 8009 | // ConnectClicked provides a mock function for the type MockButtonWidget | 8009 | // ConnectClicked provides a mock function for the type MockButtonWidget | ||
| 8010 | - | func (_mock *MockButtonWidget) ConnectClicked(callback func()) uint32 { | 8010 | + | func (_mock *MockButtonWidget) ConnectClicked(callback func()) uint { |
| 8011 | ret := _mock.Called(callback) | 8011 | ret := _mock.Called(callback) | ||
| 8012 | 8012 | ||||
| 8013 | if len(ret) == 0 { | 8013 | if len(ret) == 0 { | ||
| 8014 | panic("no return value specified for ConnectClicked") | 8014 | panic("no return value specified for ConnectClicked") | ||
| 8015 | } | 8015 | } | ||
| 8016 | 8016 | ||||
| 8017 | - | var r0 uint32 | 8017 | + | var r0 uint |
| 8018 | - | if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok { | 8018 | + | if returnFunc, ok := ret.Get(0).(func(func()) uint); ok { |
| 8019 | r0 = returnFunc(callback) | 8019 | r0 = returnFunc(callback) | ||
| 8020 | } else { | 8020 | } else { | ||
| 8021 | - | r0 = ret.Get(0).(uint32) | 8021 | + | r0 = ret.Get(0).(uint) |
| 8022 | } | 8022 | } | ||
| 8023 | return r0 | 8023 | return r0 | ||
| 8024 | } | 8024 | } | ||
| @@ -8047,12 +8047,12 @@ func (_c *MockButtonWidget_ConnectClicked_Call) Run(run func(callback func())) * | |||||
| 8047 | return _c | 8047 | return _c | ||
| 8048 | } | 8048 | } | ||
| 8049 | 8049 | ||||
| 8050 | - | func (_c *MockButtonWidget_ConnectClicked_Call) Return(v uint32) *MockButtonWidget_ConnectClicked_Call { | 8050 | + | func (_c *MockButtonWidget_ConnectClicked_Call) Return(v uint) *MockButtonWidget_ConnectClicked_Call { |
| 8051 | _c.Call.Return(v) | 8051 | _c.Call.Return(v) | ||
| 8052 | return _c | 8052 | return _c | ||
| 8053 | } | 8053 | } | ||
| 8054 | 8054 | ||||
| 8055 | - | func (_c *MockButtonWidget_ConnectClicked_Call) RunAndReturn(run func(callback func()) uint32) *MockButtonWidget_ConnectClicked_Call { | 8055 | + | func (_c *MockButtonWidget_ConnectClicked_Call) RunAndReturn(run func(callback func()) uint) *MockButtonWidget_ConnectClicked_Call { |
| 8056 | _c.Call.Return(run) | 8056 | _c.Call.Return(run) | ||
| 8057 | return _c | 8057 | return _c | ||
| 8058 | } | 8058 | } | ||
M internal/ui/layout/split_test.go +2 -2
| @@ -29,10 +29,10 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or | |||||
| 29 | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | 29 | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | - | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 32 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 33 | 33 | ||||
| 34 | // Since allocation is 0, ApplyRatio returns false and callbacks are registered | 34 | // Since allocation is 0, ApplyRatio returns false and callbacks are registered | ||
| 35 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 35 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 36 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 36 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
M internal/ui/layout/stacked.go +3 -3
| @@ -35,7 +35,7 @@ type stackedPane struct { | |||||
| 35 | isActive bool | 35 | isActive bool | ||
| 36 | 36 | ||||
| 37 | // Signal handler IDs for cleanup on removal | 37 | // Signal handler IDs for cleanup on removal | ||
| 38 | - | closeClickSignalID uint32 | 38 | + | closeClickSignalID uint |
| 39 | closeButton ButtonWidget // stored for signal disconnection | 39 | closeButton ButtonWidget // stored for signal disconnection | ||
| 40 | 40 | ||||
| 41 | // Retained callback for GestureClick to prevent GC | 41 | // Retained callback for GestureClick to prevent GC | ||
| @@ -183,7 +183,7 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||||
| 183 | // Returns the retained callback (to prevent GC) and close signal ID for disconnection. | 183 | // Returns the retained callback (to prevent GC) and close signal ID for disconnection. | ||
| 184 | func (sv *StackedView) connectTitleBarHandlers( | 184 | func (sv *StackedView) connectTitleBarHandlers( | ||
| 185 | tb titleBarComponents, paneID string, | 185 | tb titleBarComponents, paneID string, | ||
| 186 | - | ) (titleClickCallback interface{}, closeSignalID uint32) { | 186 | + | ) (titleClickCallback interface{}, closeSignalID uint) { |
| 187 | // Connect title bar click handler using GestureClick | 187 | // Connect title bar click handler using GestureClick | ||
| 188 | // This prevents event propagation issues with nested buttons | 188 | // This prevents event propagation issues with nested buttons | ||
| 189 | clickCtrl := gtk.NewGestureClick() | 189 | clickCtrl := gtk.NewGestureClick() | ||
| @@ -259,7 +259,7 @@ func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) { | |||||
| 259 | 259 | ||||
| 260 | // disconnectButtonSignal safely disconnects a signal from a button widget. | 260 | // disconnectButtonSignal safely disconnects a signal from a button widget. | ||
| 261 | // Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations. | 261 | // Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations. | ||
| 262 | - | func disconnectButtonSignal(btn ButtonWidget, signalID uint32) { | 262 | + | func disconnectButtonSignal(btn ButtonWidget, signalID uint) { |
| 263 | if btn == nil || signalID == 0 { | 263 | if btn == nil || signalID == 0 { | ||
| 264 | return | 264 | return | ||
| 265 | } | 265 | } | ||
M internal/ui/layout/stacked_test.go +2 -2
| @@ -70,7 +70,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox | |||||
| 70 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | 70 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | ||
| 71 | 71 | ||||
| 72 | // Close button click handler | 72 | // Close button click handler | ||
| 73 | - | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | 73 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once() |
| 74 | 74 | ||||
| 75 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | 75 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | ||
| 76 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | 76 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | ||
| @@ -593,7 +593,7 @@ func setupInsertPaneMocks( | |||||
| 593 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | 593 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | ||
| 594 | 594 | ||||
| 595 | // Close button click handler | 595 | // Close button click handler | ||
| 596 | - | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once() | 596 | + | mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once() |
| 597 | 597 | ||||
| 598 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | 598 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | ||
| 599 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | 599 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | ||
M internal/ui/layout/tree_test.go +12 -12
| @@ -122,9 +122,9 @@ func TestBuild_HorizontalSplit(t *testing.T) { | |||||
| 122 | mockRightStackBox.EXPECT().SetVisible(true).Once() | 122 | mockRightStackBox.EXPECT().SetVisible(true).Once() | ||
| 123 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | 123 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | ||
| 124 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | 124 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | ||
| 125 | - | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 125 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 126 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | 126 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | ||
| 127 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 127 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 128 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 128 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 129 | 129 | ||||
| 130 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | 130 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | ||
| @@ -172,9 +172,9 @@ func TestBuild_VerticalSplit(t *testing.T) { | |||||
| 172 | mockBottomStackBox.EXPECT().SetVisible(true).Once() | 172 | mockBottomStackBox.EXPECT().SetVisible(true).Once() | ||
| 173 | mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once() | 173 | mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once() | ||
| 174 | mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once() | 174 | mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once() | ||
| 175 | - | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 175 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 176 | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | 176 | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | ||
| 177 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 177 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 178 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 178 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 179 | 179 | ||||
| 180 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | 180 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | ||
| @@ -241,9 +241,9 @@ func TestBuild_NestedSplits(t *testing.T) { | |||||
| 241 | mockStackBox2.EXPECT().SetVisible(true).Once() | 241 | mockStackBox2.EXPECT().SetVisible(true).Once() | ||
| 242 | mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once() | 242 | mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once() | ||
| 243 | mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once() | 243 | mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once() | ||
| 244 | - | mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 244 | + | mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 245 | mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | 245 | mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | ||
| 246 | - | mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 246 | + | mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 247 | mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 247 | mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 248 | 248 | ||||
| 249 | // Outer split (horizontal) gets inner paned and pane3 | 249 | // Outer split (horizontal) gets inner paned and pane3 | ||
| @@ -255,9 +255,9 @@ func TestBuild_NestedSplits(t *testing.T) { | |||||
| 255 | mockStackBox3.EXPECT().SetVisible(true).Once() | 255 | mockStackBox3.EXPECT().SetVisible(true).Once() | ||
| 256 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | 256 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | ||
| 257 | mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once() | 257 | mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once() | ||
| 258 | - | mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 258 | + | mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 259 | mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | 259 | mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | ||
| 260 | - | mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 260 | + | mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 261 | mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 261 | mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 262 | 262 | ||||
| 263 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | 263 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | ||
| @@ -417,9 +417,9 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) { | |||||
| 417 | mockRightStackBox.EXPECT().SetVisible(true).Once() | 417 | mockRightStackBox.EXPECT().SetVisible(true).Once() | ||
| 418 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | 418 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | ||
| 419 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | 419 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | ||
| 420 | - | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 420 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 421 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | 421 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | ||
| 422 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 422 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 423 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 423 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 424 | 424 | ||||
| 425 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | 425 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | ||
| @@ -542,9 +542,9 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||||
| 542 | mockRightStackBox.EXPECT().SetVisible(true).Once() | 542 | mockRightStackBox.EXPECT().SetVisible(true).Once() | ||
| 543 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | 543 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | ||
| 544 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | 544 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | ||
| 545 | - | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | 545 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() |
| 546 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice() | 546 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice() | ||
| 547 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 547 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() |
| 548 | 548 | ||||
| 549 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 549 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 550 | 550 | ||||
M internal/ui/layout/widgets.go +3 -3
| @@ -95,8 +95,8 @@ type PanedWidget interface { | |||||
| 95 | GetShrinkEndChild() bool | 95 | GetShrinkEndChild() bool | ||
| 96 | 96 | ||||
| 97 | // Signals | 97 | // Signals | ||
| 98 | - | ConnectMap(callback func()) uint32 | 98 | + | ConnectMap(callback func()) uint |
| 99 | - | ConnectNotifyPosition(callback func()) uint32 | 99 | + | ConnectNotifyPosition(callback func()) uint |
| 100 | 100 | ||||
| 101 | // Tick callback for frame-based updates (returns signal ID) | 101 | // Tick callback for frame-based updates (returns signal ID) | ||
| 102 | // Callback returns true to continue, false to stop | 102 | // Callback returns true to continue, false to stop | ||
| @@ -182,7 +182,7 @@ type ButtonWidget interface { | |||||
| 182 | SetIconName(iconName string) | 182 | SetIconName(iconName string) | ||
| 183 | 183 | ||||
| 184 | // Connect click handler, returns signal ID for disconnection | 184 | // Connect click handler, returns signal ID for disconnection | ||
| 185 | - | ConnectClicked(callback func()) uint32 | 185 | + | ConnectClicked(callback func()) uint |
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | // Paintable represents a graphics texture that can be displayed in an image. | 188 | // Paintable represents a graphics texture that can be displayed in an image. | ||
M internal/ui/theme/css.go +9 -2
| @@ -527,12 +527,19 @@ func generatePaneCSS(p Palette) string { | |||||
| 527 | min-width: 32px; | 527 | min-width: 32px; | ||
| 528 | min-height: 32px; | 528 | min-height: 32px; | ||
| 529 | color: var(--muted); | 529 | color: var(--muted); | ||
| 530 | + | opacity: 0.4; | |||
| 530 | } | 531 | } | ||
| 531 | 532 | ||||
| 532 | .loading-skeleton-logo { | 533 | .loading-skeleton-logo { | ||
| 533 | opacity: 0.08; | 534 | opacity: 0.08; | ||
| 534 | - | min-width: 512px; | 535 | + | min-width: 256px; |
| 535 | - | min-height: 512px; | 536 | + | min-height: 256px; |
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | .loading-skeleton-version { | |||
| 540 | + | opacity: 0.25; | |||
| 541 | + | font-size: 13px; | |||
| 542 | + | color: var(--muted); | |||
| 536 | } | 543 | } | ||
| 537 | 544 | ||||
| 538 | /* Pane border - default transparent */ | 545 | /* Pane border - default transparent */ | ||