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 | 107 | aur/*/LICENSE | |
| 108 | 108 | aur/*/dev.bnema.Dumber.desktop | |
| 109 | 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 | 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 | 56 | ## [0.26.1] - 2026-01-25 | |
| 6 | 57 | ||
| 7 | 58 | ### Fixed | |
M cmd/dumber/main.go +2 -0
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 29 | 29 | "github.com/bnema/dumber/internal/logging" | |
| 30 | 30 | "github.com/bnema/dumber/internal/ui" | |
| 31 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 31 | 32 | "github.com/bnema/dumber/internal/ui/theme" | |
| 32 | 33 | "github.com/rs/zerolog" | |
| 33 | 34 | ) | |
| @@ -71,6 +72,7 @@ func main() { | |||
| 71 | 72 | ||
| 72 | 73 | func runGUI() int { | |
| 73 | 74 | runtime.LockOSThread() | |
| 75 | + | component.SetSkeletonVersion(version) | |
| 74 | 76 | timer := bootstrap.NewStartupTimer() | |
| 75 | 77 | ||
| 76 | 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 | 432 | [media] | |
| 433 | 433 | hardware_decoding = "auto" # HW preferred, SW fallback | |
| 434 | 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 | 436 | force_vsync = false # Let compositor handle VSync | |
| 437 | 437 | gl_rendering_mode = "auto" # GStreamer picks best GL API | |
| 438 | 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 | 452 | **Diagnostics CLI:** | |
| 442 | 453 | ```bash | |
| 443 | 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 | 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 | 544 | | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving | | |
| 534 | 545 | | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems | | |
| 535 | 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 | 560 | **Example:** | |
| 550 | 561 | ```toml | |
| 551 | 562 | [performance] | |
| 552 | - | profile = "default" # Most users - no tuning needed | |
| 563 | + | profile = "default" # WebKit baseline default | |
| 553 | 564 | ||
| 554 | - | # For low-RAM systems: | |
| 555 | - | # profile = "lite" | |
| 565 | + | # For WebKit baseline behavior: | |
| 566 | + | # profile = "default" | |
| 556 | 567 | ||
| 557 | 568 | # For heavy pages (GitHub PRs, complex web apps): | |
| 558 | 569 | # profile = "max" | |
M docs/reference/index.md +1 -0
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu | |
| 7 | 7 | - [Keybindings](./keybindings.md) - All keyboard shortcuts | |
| 8 | 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 | 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 | 30 | **Solution:** | |
| 31 | 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 | 43 | ```toml | |
| 34 | 44 | # ~/.config/dumber/config.toml | |
| 35 | 45 | [media] | |
| 36 | 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 | 50 | - AMD: `libva-mesa-driver` | |
| 40 | 51 | - Intel: `intel-media-driver` | |
| 41 | 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 | 61 | ### Screen flickering on Wayland | |
| 44 | 62 | ||
M go.mod +3 -3
| @@ -3,7 +3,7 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/puregotk-webkit v0.0.9 | |
| 6 | + | github.com/bnema/puregotk-webkit v0.0.10 | |
| 7 | 7 | github.com/charmbracelet/bubbles v0.21.1 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -22,13 +22,13 @@ require ( | |||
| 22 | 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 | 27 | require ( | |
| 28 | 28 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 29 | 29 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 30 | 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 | 32 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | |
| 33 | 33 | github.com/charmbracelet/x/term v0.2.2 // indirect | |
| 34 | 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 | 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 | 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 | 1444 | // The first argument is typically a *testing.T value. | |
| 1286 | 1445 | func NewMockFontDetector(t interface { | |
M internal/application/port/updater.go +4 -0
| @@ -3,10 +3,14 @@ package port | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | ||
| 7 | 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 | 14 | // UpdateChecker checks for available updates from a remote source. | |
| 11 | 15 | type UpdateChecker interface { | |
| 12 | 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 | 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 | 15 | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | |
| 7 | 16 | // Zero values mean "use WebKit defaults". | |
| 8 | 17 | type MemoryPressureConfig struct { | |
| @@ -42,6 +51,14 @@ type WebKitContextOptions struct { | |||
| 42 | 51 | // CacheDir is the directory for cache data. | |
| 43 | 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 | 62 | // WebProcessMemory configures memory pressure for web processes. | |
| 46 | 63 | // nil means use WebKit defaults. | |
| 47 | 64 | WebProcessMemory *MemoryPressureConfig | |
M internal/application/port/webview.go +16 -4
| @@ -52,6 +52,16 @@ type WebViewState struct { | |||
| 52 | 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 | 65 | // PopupRequest contains metadata about a popup window request. | |
| 56 | 66 | type PopupRequest struct { | |
| 57 | 67 | TargetURI string | |
| @@ -92,6 +102,8 @@ type WebViewCallbacks struct { | |||
| 92 | 102 | // OnLinkHover is called when hovering over a link, image, or media element. | |
| 93 | 103 | // The uri parameter contains the target URL, or empty string when leaving. | |
| 94 | 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 | 109 | // FindOptions configures search behavior. | |
| @@ -111,10 +123,10 @@ type FindController interface { | |||
| 111 | 123 | GetSearchText() string | |
| 112 | 124 | ||
| 113 | 125 | // Signal connections | |
| 114 | - | OnFoundText(callback func(matchCount uint)) uint32 | |
| 115 | - | OnFailedToFindText(callback func()) uint32 | |
| 116 | - | OnCountedMatches(callback func(matchCount uint)) uint32 | |
| 117 | - | DisconnectSignal(id uint32) | |
| 126 | + | OnFoundText(callback func(matchCount uint)) uint | |
| 127 | + | OnFailedToFindText(callback func()) uint | |
| 128 | + | OnCountedMatches(callback func(matchCount uint)) uint | |
| 129 | + | DisconnectSignal(id uint) | |
| 118 | 130 | } | |
| 119 | 131 | ||
| 120 | 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 | 157 | return nil | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | for _, fav := range favorites { | |
| 161 | - | if fav == nil || fav.URL == "" { | |
| 162 | - | continue | |
| 163 | - | } | |
| 164 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL) | |
| 165 | - | if ok { | |
| 166 | - | return &autocomplete.Suggestion{ | |
| 167 | - | FullText: matchedURL, | |
| 168 | - | Suffix: suffix, | |
| 169 | - | Source: autocomplete.SourceFavorite, | |
| 170 | - | Title: fav.Title, | |
| 160 | + | return bestSuggestionFromURLs(input, autocomplete.SourceFavorite, | |
| 161 | + | len(favorites), | |
| 162 | + | func(i int) string { | |
| 163 | + | if favorites[i] == nil { | |
| 164 | + | return "" | |
| 171 | 165 | } | |
| 172 | - | } | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | return nil | |
| 166 | + | return favorites[i].URL | |
| 167 | + | }, | |
| 168 | + | func(i int) string { | |
| 169 | + | if favorites[i] == nil { | |
| 170 | + | return "" | |
| 171 | + | } | |
| 172 | + | return favorites[i].Title | |
| 173 | + | }, | |
| 174 | + | ) | |
| 176 | 175 | } | |
| 177 | 176 | ||
| 178 | 177 | // findHistorySuggestion finds the best matching history entry for the input. | |
| @@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input | |||
| 189 | 188 | return nil | |
| 190 | 189 | } | |
| 191 | 190 | ||
| 192 | - | for _, entry := range entries { | |
| 193 | - | if entry == nil || entry.URL == "" { | |
| 194 | - | continue | |
| 195 | - | } | |
| 196 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL) | |
| 197 | - | if ok { | |
| 198 | - | return &autocomplete.Suggestion{ | |
| 199 | - | FullText: matchedURL, | |
| 200 | - | Suffix: suffix, | |
| 201 | - | Source: autocomplete.SourceHistory, | |
| 202 | - | Title: entry.Title, | |
| 191 | + | return bestSuggestionFromURLs(input, autocomplete.SourceHistory, | |
| 192 | + | len(entries), | |
| 193 | + | func(i int) string { | |
| 194 | + | if entries[i] == nil { | |
| 195 | + | return "" | |
| 203 | 196 | } | |
| 204 | - | } | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | return nil | |
| 197 | + | return entries[i].URL | |
| 198 | + | }, | |
| 199 | + | func(i int) string { | |
| 200 | + | if entries[i] == nil { | |
| 201 | + | return "" | |
| 202 | + | } | |
| 203 | + | return entries[i].Title | |
| 204 | + | }, | |
| 205 | + | ) | |
| 208 | 206 | } | |
| 209 | 207 | ||
| 210 | 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 | 239 | func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion { | |
| 242 | - | for _, u := range urls { | |
| 243 | - | if u == "" { | |
| 244 | - | continue | |
| 240 | + | suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls) | |
| 241 | + | if ok { | |
| 242 | + | return &autocomplete.Suggestion{ | |
| 243 | + | FullText: matchedURL, | |
| 244 | + | Suffix: suffix, | |
| 245 | + | Source: source, | |
| 245 | 246 | } | |
| 246 | - | suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u) | |
| 247 | - | if ok { | |
| 248 | - | return &autocomplete.Suggestion{ | |
| 249 | - | FullText: matchedURL, | |
| 250 | - | Suffix: suffix, | |
| 251 | - | Source: source, | |
| 252 | - | } | |
| 247 | + | } | |
| 248 | + | return nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func bestSuggestionFromURLs( | |
| 252 | + | input string, | |
| 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 | 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 | 55 | assert.True(t, output.Found) | |
| 56 | 56 | assert.NotNil(t, output.Suggestion) | |
| 57 | 57 | assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source) | |
| 58 | - | assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText) | |
| 59 | - | assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix) | |
| 58 | + | assert.Equal(t, "github.com", output.Suggestion.FullText) | |
| 59 | + | assert.Equal(t, ".com", output.Suggestion.Suffix) | |
| 60 | 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 | 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 | 189 | func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) { | |
| 171 | 190 | ctx := testContext() | |
| 172 | 191 | ||
M internal/application/usecase/check_update.go +10 -0
| @@ -2,6 +2,7 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 8 | "github.com/bnema/dumber/internal/domain/build" | |
| @@ -53,6 +54,15 @@ func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) ( | |||
| 53 | 54 | ||
| 54 | 55 | info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version) | |
| 55 | 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 | 66 | log.Warn().Err(err).Msg("update check failed") | |
| 57 | 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 | 43 | ctx context.Context | |
| 44 | 44 | ||
| 45 | 45 | controller port.FindController | |
| 46 | - | signalIDs []uint32 | |
| 46 | + | signalIDs []uint | |
| 47 | 47 | ||
| 48 | 48 | query string | |
| 49 | 49 | matchCount uint | |
M internal/application/usecase/navigate.go +233 -52
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "net/url" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| @@ -20,12 +21,32 @@ const ( | |||
| 20 | 21 | ||
| 21 | 22 | // logURLMaxLen is the max length for URLs in log messages. | |
| 22 | 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 | 32 | // historyRecord holds data for async history recording. | |
| 26 | 33 | type historyRecord struct { | |
| 27 | - | url string | |
| 28 | - | timestamp time.Time | |
| 34 | + | url string | |
| 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 | 52 | // NavigateUseCase handles URL navigation with history recording and zoom application. | |
| @@ -33,7 +54,9 @@ type NavigateUseCase struct { | |||
| 33 | 54 | historyRepo repository.HistoryRepository | |
| 34 | 55 | zoomRepo repository.ZoomRepository | |
| 35 | 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 | 61 | // Async history recording | |
| 39 | 62 | historyQueue chan historyRecord | |
| @@ -57,6 +80,7 @@ func NewNavigateUseCase( | |||
| 57 | 80 | historyRepo: historyRepo, | |
| 58 | 81 | zoomRepo: zoomRepo, | |
| 59 | 82 | defaultZoom: defaultZoom, | |
| 83 | + | recentVisits: make(map[string]paneHistoryState), | |
| 60 | 84 | historyQueue: make(chan historyRecord, historyQueueSize), | |
| 61 | 85 | done: make(chan struct{}), | |
| 62 | 86 | ctx: context.Background(), | |
| @@ -72,7 +96,9 @@ func NewNavigateUseCase( | |||
| 72 | 96 | // Close shuts down the background history worker and drains any pending records. | |
| 73 | 97 | // This should be called when the application is shutting down. | |
| 74 | 98 | func (uc *NavigateUseCase) Close() { | |
| 75 | - | close(uc.done) | |
| 99 | + | uc.closeOnce.Do(func() { | |
| 100 | + | close(uc.done) | |
| 101 | + | }) | |
| 76 | 102 | uc.wg.Wait() | |
| 77 | 103 | } | |
| 78 | 104 | ||
| @@ -117,36 +143,74 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||
| 117 | 143 | // This prevents inflation from redirects and rapid navigation. | |
| 118 | 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 | 150 | // RecordHistory queues a history entry for async recording. | |
| 121 | 151 | // This is non-blocking to avoid SQLite I/O on the GTK main thread. | |
| 122 | 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 | 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/) | |
| 127 | - | url = normalizeURLForHistory(url) | |
| 160 | + | canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{ | |
| 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 | 169 | now := time.Now() | |
| 132 | - | if lastTime, ok := uc.recentVisits.Load(url); ok { | |
| 133 | - | if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow { | |
| 134 | - | log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window") | |
| 135 | - | return | |
| 136 | - | } | |
| 170 | + | ||
| 171 | + | uc.recentMu.Lock() | |
| 172 | + | state := uc.recentVisits[paneID] | |
| 173 | + | ||
| 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 | 196 | // Non-blocking send to async queue | |
| 141 | 197 | select { | |
| 142 | - | case uc.historyQueue <- historyRecord{url: url, timestamp: now}: | |
| 143 | - | log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history record queued") | |
| 198 | + | case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}: | |
| 144 | 199 | default: | |
| 145 | 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 | 214 | // historyWorker is a background goroutine that processes history records. | |
| 151 | 215 | // It drains the queue and persists records to the database without blocking the UI. | |
| 152 | 216 | func (uc *NavigateUseCase) historyWorker() { | |
| @@ -156,22 +220,44 @@ func (uc *NavigateUseCase) historyWorker() { | |||
| 156 | 220 | Str("component", "history-worker"). | |
| 157 | 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 | 249 | for { | |
| 160 | 250 | select { | |
| 161 | 251 | case record := <-uc.historyQueue: | |
| 162 | - | uc.persistHistory(uc.ctx, record) | |
| 252 | + | pending[record.url] += record.visits | |
| 253 | + | case <-ticker.C: | |
| 254 | + | flushPending() | |
| 163 | 255 | case <-uc.done: | |
| 164 | - | // Drain remaining records before shutdown | |
| 165 | 256 | log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue") | |
| 166 | - | for { | |
| 167 | - | select { | |
| 168 | - | case record := <-uc.historyQueue: | |
| 169 | - | uc.persistHistory(uc.ctx, record) | |
| 170 | - | default: | |
| 171 | - | log.Debug().Msg("history worker shutdown complete") | |
| 172 | - | return | |
| 173 | - | } | |
| 174 | - | } | |
| 257 | + | drainQueue() | |
| 258 | + | flushPending() | |
| 259 | + | log.Debug().Msg("history worker shutdown complete") | |
| 260 | + | return | |
| 175 | 261 | } | |
| 176 | 262 | } | |
| 177 | 263 | } | |
| @@ -189,13 +275,33 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec | |||
| 189 | 275 | } | |
| 190 | 276 | ||
| 191 | 277 | if existing != nil { | |
| 192 | - | // Increment visit count | |
| 193 | - | if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil { | |
| 194 | - | log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count") | |
| 278 | + | delta := max(1, record.visits) | |
| 279 | + | if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok { | |
| 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 | 301 | } else { | |
| 197 | 302 | // Create new entry | |
| 198 | 303 | entry := entity.NewHistoryEntry(record.url, "") | |
| 304 | + | entry.VisitCount = int64(max(1, record.visits)) | |
| 199 | 305 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | |
| 200 | 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 | 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 | 313 | log := logging.FromContext(ctx) | |
| 208 | 314 | ||
| 209 | - | // Normalize URL to match storage (e.g., github.com/ -> github.com) | |
| 210 | - | url = normalizeURLForHistory(url) | |
| 211 | - | log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title") | |
| 315 | + | // Canonicalize URL the same way history records are persisted. | |
| 316 | + | historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{ | |
| 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 | 326 | if err != nil { | |
| 215 | 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 | 331 | // Entry doesn't exist - don't create it here | |
| 220 | 332 | // Initial navigation should have already created the entry | |
| 221 | 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 | 335 | return nil | |
| 224 | 336 | } | |
| 225 | 337 | ||
| @@ -250,20 +362,89 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error | |||
| 250 | 362 | return webview.Stop(ctx) | |
| 251 | 363 | } | |
| 252 | 364 | ||
| 253 | - | // normalizeURLForHistory normalizes a URL for history storage. | |
| 254 | - | // Strips trailing slash to avoid duplicates like github.com vs github.com/ | |
| 255 | - | func normalizeURLForHistory(url string) string { | |
| 256 | - | // Don't strip if URL is just the protocol + domain (e.g., "https://github.com") | |
| 257 | - | // Only strip trailing slash from paths | |
| 258 | - | if strings.HasSuffix(url, "/") { | |
| 259 | - | // Check if it's just domain with trailing slash (e.g., "https://github.com/") | |
| 260 | - | // by counting slashes - protocol has 2, domain adds 0, path adds more | |
| 261 | - | if strings.Count(url, "/") == 3 { | |
| 262 | - | // It's "https://domain.com/" - strip the trailing slash | |
| 263 | - | return strings.TrimSuffix(url, "/") | |
| 365 | + | func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string { | |
| 366 | + | raw = strings.TrimSpace(raw) | |
| 367 | + | if raw == "" { | |
| 368 | + | return "" | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | parsed, err := url.Parse(raw) | |
| 372 | + | if err != nil { | |
| 373 | + | return strings.TrimSuffix(raw, "/") | |
| 374 | + | } | |
| 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 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "syscall" | |
| 11 | 12 | "time" | |
| @@ -24,6 +25,7 @@ const ( | |||
| 24 | 25 | recentSessionsLimit = 200 | |
| 25 | 26 | lockDirPerm = 0o755 | |
| 26 | 27 | lockFilePerm = 0o600 | |
| 28 | + | markerFilePerm = 0o644 | |
| 27 | 29 | ) | |
| 28 | 30 | ||
| 29 | 31 | type BrowserSession struct { | |
| @@ -95,6 +97,19 @@ func StartBrowserSession( | |||
| 95 | 97 | // error handling during shutdown sequences where persistFn may be called multiple times. | |
| 96 | 98 | persistFn := func(persistCtx context.Context) error { | |
| 97 | 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 | 113 | saveCtx := corelogging.WithContext(context.Background(), logger) | |
| 99 | 114 | if err := sessionRepo.Save(saveCtx, session); err != nil { | |
| 100 | 115 | persistErr = fmt.Errorf("save session: %w", err) | |
| @@ -105,6 +120,11 @@ func StartBrowserSession( | |||
| 105 | 120 | Str("session_id", string(session.ID)). | |
| 106 | 121 | Str("type", string(session.Type)). | |
| 107 | 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 | 129 | lf, lp, lockErr := lockSessionFile(lockDir, session.ID) | |
| 110 | 130 | if lockErr != nil { | |
| @@ -133,6 +153,13 @@ func StartBrowserSession( | |||
| 133 | 153 | lp := lockPath | |
| 134 | 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 | 163 | if lf != nil { | |
| 137 | 164 | _ = unlockAndClose(lf) | |
| 138 | 165 | _ = os.Remove(lp) | |
| @@ -263,6 +290,158 @@ func unlockAndClose(f *os.File) error { | |||
| 263 | 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 | 445 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | |
| 267 | 446 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | |
| 268 | 447 | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | |
| @@ -306,5 +485,73 @@ func runSessionCleanupAsync( | |||
| 306 | 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 | 46 | configureRenderingEnvironment(ctx, cfg, &perfSettings, logger) | |
| 47 | 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 | 54 | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 51 | 55 | if err != nil { | |
| 52 | 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 | 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 | 116 | wkOpts := port.WebKitContextOptions{ | |
| 102 | - | DataDir: dataDir, | |
| 103 | - | CacheDir: cacheDir, | |
| 117 | + | DataDir: dataDir, | |
| 118 | + | CacheDir: cacheDir, | |
| 119 | + | CookiePolicy: cookiePolicy, | |
| 120 | + | ITPEnabled: itpEnabled, | |
| 104 | 121 | } | |
| 105 | 122 | ||
| 106 | 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 | 1 | // Package autocomplete provides domain types and logic for URL autocompletion. | |
| 2 | 2 | package autocomplete | |
| 3 | 3 | ||
| 4 | - | import "strings" | |
| 4 | + | import ( | |
| 5 | + | stdurl "net/url" | |
| 6 | + | "strings" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ) | |
| 5 | 9 | ||
| 6 | 10 | // SuggestionSource indicates the origin of an autocomplete suggestion. | |
| 7 | 11 | type SuggestionSource int | |
| @@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin | |||
| 78 | 82 | ||
| 79 | 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 | 153 | SampleMemory: false, | |
| 154 | 154 | DebugFrames: false, | |
| 155 | 155 | }, | |
| 156 | + | Privacy: PrivacyConfig{ | |
| 157 | + | CookiePolicy: CookiePolicyNoThirdParty, | |
| 158 | + | ITPEnabled: true, | |
| 159 | + | }, | |
| 156 | 160 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | |
| 157 | 161 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | |
| 158 | 162 | Workspace: WorkspaceConfig{ | |
| @@ -289,7 +293,7 @@ func DefaultConfig() *Config { | |||
| 289 | 293 | Prefix: "", | |
| 290 | 294 | }, | |
| 291 | 295 | Performance: PerformanceConfig{ | |
| 292 | - | Profile: ProfileDefault, // Use WebKit defaults, no tuning | |
| 296 | + | Profile: ProfileDefault, // Use WebKit defaults by default | |
| 293 | 297 | ZoomCacheSize: defaultZoomCacheSize, | |
| 294 | 298 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 295 | 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 | 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 | 190 | switch strings.ToLower(string(config.Rendering.Mode)) { | |
| 181 | 191 | case "", string(RenderingModeAuto): | |
| 182 | 192 | config.Rendering.Mode = RenderingModeAuto | |
| @@ -200,15 +210,35 @@ func normalizeConfig(config *Config) { | |||
| 200 | 210 | default: | |
| 201 | 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 { | |
| 205 | - | case ThemePreferDark, ThemePreferLight, ThemeDefault: | |
| 206 | - | case "": | |
| 228 | + | func normalizeAppearance(config *Config) { | |
| 229 | + | switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) { | |
| 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 | 235 | config.Appearance.ColorScheme = ThemeDefault | |
| 208 | 236 | default: | |
| 209 | 237 | config.Appearance.ColorScheme = ThemeDefault | |
| 210 | 238 | } | |
| 239 | + | } | |
| 211 | 240 | ||
| 241 | + | func normalizeMedia(config *Config) { | |
| 212 | 242 | switch strings.ToLower(string(config.Media.HardwareDecodingMode)) { | |
| 213 | 243 | case "", string(HardwareDecodingAuto): | |
| 214 | 244 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | |
| @@ -232,9 +262,6 @@ func normalizeConfig(config *Config) { | |||
| 232 | 262 | default: | |
| 233 | 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 | 267 | func normalizePerformanceProfile(config *Config) { | |
| @@ -247,6 +274,8 @@ func normalizePerformanceProfile(config *Config) { | |||
| 247 | 274 | config.Performance.Profile = ProfileMax | |
| 248 | 275 | case string(ProfileCustom): | |
| 249 | 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 | 402 | m.setDebugDefaults(defaults) | |
| 374 | 403 | m.setAppearanceDefaults(defaults) | |
| 375 | 404 | m.setRenderingDefaults(defaults) | |
| 405 | + | m.setPrivacyDefaults(defaults) | |
| 376 | 406 | m.setWorkspaceDefaults(defaults) | |
| 377 | 407 | m.setContentFilteringDefaults(defaults) | |
| 378 | 408 | m.setClipboardDefaults(defaults) | |
| @@ -447,6 +477,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) { | |||
| 447 | 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 | 485 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |
| 451 | 486 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | |
| 452 | 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 | 27 | Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"` | |
| 28 | 28 | // Rendering controls WebKit, GTK and compositor behavior. | |
| 29 | 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 | 32 | // Media controls video playback and hardware acceleration | |
| 31 | 33 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | |
| 32 | 34 | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | |
| @@ -48,6 +50,18 @@ const ( | |||
| 48 | 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 | 65 | // GSKRendererMode controls the GTK Scene Kit renderer selection. | |
| 52 | 66 | type GSKRendererMode string | |
| 53 | 67 | ||
| @@ -103,6 +117,15 @@ type RenderingConfig struct { | |||
| 103 | 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 | 129 | // HardwareDecodingMode controls video hardware acceleration. | |
| 107 | 130 | type HardwareDecodingMode string | |
| 108 | 131 | ||
M internal/infrastructure/config/validation.go +13 -0
| @@ -26,6 +26,7 @@ func validateConfig(config *Config) error { | |||
| 26 | 26 | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | |
| 27 | 27 | validationErrors = append(validationErrors, validateOmnibox(config)...) | |
| 28 | 28 | validationErrors = append(validationErrors, validateRendering(config)...) | |
| 29 | + | validationErrors = append(validationErrors, validatePrivacy(config)...) | |
| 29 | 30 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 30 | 31 | validationErrors = append(validationErrors, validateSession(config)...) | |
| 31 | 32 | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |
| @@ -323,6 +324,18 @@ func validateRendering(config *Config) []string { | |||
| 323 | 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 | 339 | func validateColorScheme(config *Config) []string { | |
| 327 | 340 | switch config.Appearance.ColorScheme { | |
| 328 | 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 | 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 | |
| 187 | - | tmpPath := localPath + ".tmp" | |
| 188 | - | file, err := os.Create(tmpPath) | |
| 186 | + | // Write to temp file in cacheDir, then atomic rename into destination. | |
| 187 | + | tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp") | |
| 189 | 188 | if err != nil { | |
| 190 | 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 | 194 | if err != nil { | |
| 195 | - | _ = file.Close() | |
| 195 | + | _ = tmpFile.Close() | |
| 196 | 196 | _ = os.Remove(tmpPath) | |
| 197 | 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 | 200 | _ = os.Remove(tmpPath) | |
| 201 | 201 | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | |
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | - | // Atomic rename | |
| 205 | - | if err := os.Rename(tmpPath, localPath); err != nil { | |
| 204 | + | if err := d.renameTempFile(tmpPath, localPath); err != nil { | |
| 206 | 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 | 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 | 226 | // downloadAndCacheManifest downloads and caches the manifest. | |
| 214 | 227 | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | |
| 215 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 10 | + | "strings" | |
| 9 | 11 | "sync" | |
| 10 | 12 | "sync/atomic" | |
| 11 | 13 | "time" | |
| @@ -17,12 +19,17 @@ import ( | |||
| 17 | 19 | const ( | |
| 18 | 20 | storeDirPerm = 0o755 | |
| 19 | 21 | jsonDirPerm = 0o755 | |
| 22 | + | quarantinePerm = 0o755 | |
| 20 | 23 | mergedFilePerm = 0o644 | |
| 21 | 24 | ||
| 22 | 25 | // CacheMaxAge is the maximum age of the filter cache before it's considered stale. | |
| 23 | 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 | 33 | // Manager orchestrates the content filter lifecycle. | |
| 27 | 34 | // It handles downloading, compiling, loading, and applying filters to WebViews. | |
| 28 | 35 | type Manager struct { | |
| @@ -140,86 +147,125 @@ func (m *Manager) LoadAsync(ctx context.Context) { | |||
| 140 | 147 | return | |
| 141 | 148 | } | |
| 142 | 149 | ||
| 143 | - | go func() { | |
| 144 | - | log := logging.FromContext(ctx).With(). | |
| 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 | - | } | |
| 150 | + | go m.loadAsyncWorker(ctx) | |
| 151 | + | } | |
| 180 | 152 | ||
| 181 | - | // Download filters | |
| 182 | - | m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."}) | |
| 183 | - | paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) { | |
| 184 | - | msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total) | |
| 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 | - | } | |
| 153 | + | func (m *Manager) loadAsyncWorker(ctx context.Context) { | |
| 154 | + | log := logging.FromContext(ctx).With(). | |
| 155 | + | Str("component", "filter-manager"). | |
| 156 | + | Logger() | |
| 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 | |
| 196 | - | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |
| 197 | - | if err != nil { | |
| 198 | - | log.Error().Err(err).Msg("failed to merge filter files") | |
| 199 | - | m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"}) | |
| 200 | - | return | |
| 201 | - | } | |
| 163 | + | if m.loadFromCache(ctx) { | |
| 164 | + | m.checkStaleCacheAndUpdate(ctx) | |
| 165 | + | return | |
| 166 | + | } | |
| 202 | 167 | ||
| 203 | - | // Compile the merged filter | |
| 204 | - | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |
| 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 | - | } | |
| 168 | + | m.downloadCompileAndActivate(ctx) | |
| 169 | + | } | |
| 210 | 170 | ||
| 211 | - | m.filterMu.Lock() | |
| 212 | - | m.filter = filter | |
| 213 | - | m.filterMu.Unlock() | |
| 171 | + | func (m *Manager) loadFromCache(ctx context.Context) bool { | |
| 172 | + | log := logging.FromContext(ctx).With(). | |
| 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 | 246 | version := m.getCachedVersion() | |
| 216 | 247 | m.setStatus(FilterStatus{ | |
| 217 | 248 | State: StateActive, | |
| 218 | - | Message: "Filters active", | |
| 249 | + | Message: "Filters active (update skipped)", | |
| 219 | 250 | Version: version, | |
| 220 | 251 | }) | |
| 221 | - | log.Info().Str("version", version).Msg("filters compiled and active") | |
| 222 | - | }() | |
| 252 | + | return | |
| 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 | 271 | // mergeJSONFiles combines multiple JSON rule files into one. | |
| @@ -345,16 +391,27 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||
| 345 | 391 | if err != nil { | |
| 346 | 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 | 400 | // Merge and compile | |
| 350 | 401 | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |
| 351 | 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 | 407 | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |
| 356 | 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 | 417 | m.filterMu.Lock() | |
| @@ -372,6 +429,109 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||
| 372 | 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 | 535 | // Enable enables content filtering. | |
| 376 | 536 | func (m *Manager) Enable(ctx context.Context) { | |
| 377 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "os" | |
| 6 | 7 | "path/filepath" | |
| 7 | 8 | "sync" | |
| @@ -11,6 +12,7 @@ import ( | |||
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 12 | 13 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 15 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 14 | 16 | "github.com/stretchr/testify/assert" | |
| 15 | 17 | "github.com/stretchr/testify/mock" | |
| 16 | 18 | "github.com/stretchr/testify/require" | |
| @@ -54,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) { | |||
| 54 | 56 | ||
| 55 | 57 | mockStore.EXPECT(). | |
| 56 | 58 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 57 | - | Return(nil, nil) | |
| 59 | + | Return(&webkit.UserContentFilter{}, nil) | |
| 58 | 60 | ||
| 59 | 61 | mockDownloader.EXPECT(). | |
| 60 | 62 | GetCachedManifest(). | |
| @@ -144,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||
| 144 | 146 | // Compile should be called | |
| 145 | 147 | mockStore.EXPECT(). | |
| 146 | 148 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 147 | - | Return(nil, nil) | |
| 149 | + | Return(&webkit.UserContentFilter{}, nil) | |
| 148 | 150 | ||
| 149 | 151 | // GetCachedManifest for version | |
| 150 | 152 | mockDownloader.EXPECT(). | |
| @@ -241,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) | |||
| 241 | 243 | // Compile called | |
| 242 | 244 | mockStore.EXPECT(). | |
| 243 | 245 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 244 | - | Return(nil, nil) | |
| 246 | + | Return(&webkit.UserContentFilter{}, nil) | |
| 245 | 247 | ||
| 246 | 248 | // GetCachedManifest for version | |
| 247 | 249 | mockDownloader.EXPECT(). | |
| @@ -451,7 +453,7 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||
| 451 | 453 | Return([]string{jsonFile}, nil) | |
| 452 | 454 | mockStore.EXPECT(). | |
| 453 | 455 | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 454 | - | Return(nil, nil) | |
| 456 | + | Return(&webkit.UserContentFilter{}, nil) | |
| 455 | 457 | mockDownloader.EXPECT(). | |
| 456 | 458 | GetCachedManifest(). | |
| 457 | 459 | Return(&filtering.Manifest{Version: "test"}, nil) | |
| @@ -494,3 +496,114 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||
| 494 | 496 | defer mu.Unlock() | |
| 495 | 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 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "strings" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "time" | |
| 11 | + | "unicode" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 14 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -16,6 +18,8 @@ import ( | |||
| 16 | 18 | ||
| 17 | 19 | const logURLMaxLen = 60 | |
| 18 | 20 | ||
| 21 | + | var expectedFTSFailureCount atomic.Uint64 | |
| 22 | + | ||
| 19 | 23 | type historyRepo struct { | |
| 20 | 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 | 74 | func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) { | |
| 71 | - | log := logging.FromContext(ctx) | |
| 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) | |
| 75 | + | words := sanitizeFTS5QueryTokens(query) | |
| 84 | 76 | if len(words) == 0 { | |
| 85 | 77 | return []entity.HistoryMatch{}, nil | |
| 86 | 78 | } | |
| 87 | 79 | ||
| 88 | 80 | // Build FTS5 query: "word1* word2*" (implicit AND between terms) | |
| 89 | - | // Sanitize each word to remove FTS5 special characters | |
| 90 | 81 | parts := make([]string, len(words)) | |
| 91 | 82 | for i, word := range words { | |
| 92 | - | parts[i] = sanitizeFTS5Word(word) + "*" | |
| 83 | + | parts[i] = word + "*" | |
| 93 | 84 | } | |
| 94 | 85 | ftsQuery := strings.Join(parts, " ") | |
| 95 | 86 | ||
| @@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||
| 97 | 88 | // Use domain-boosted query for URL search to prioritize domain matches | |
| 98 | 89 | // Use sanitized first word for domain boost (intentional: domain boost targets first search term) | |
| 99 | 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 | 92 | Query: ftsQuery, | |
| 102 | 93 | Limit: int64(limit), | |
| 103 | 94 | }) | |
| 104 | 95 | if urlErr != nil { | |
| 105 | - | log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed") | |
| 96 | + | logFTSSearchError(ctx, "url", query, urlErr) | |
| 106 | 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 | 102 | Limit: int64(limit), | |
| 112 | 103 | }) | |
| 113 | 104 | if titleErr != nil { | |
| 114 | - | log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed") | |
| 105 | + | logFTSSearchError(ctx, "title", query, titleErr) | |
| 115 | 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. | |
| 167 | - | // FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR | |
| 168 | - | // Note: Periods are handled earlier by replacing with spaces (tokenizer separator) | |
| 157 | + | // sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens. | |
| 158 | + | func sanitizeFTS5QueryTokens(query string) []string { | |
| 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 | 195 | func sanitizeFTS5Word(word string) string { | |
| 170 | - | // Remove characters that have special meaning in FTS5 | |
| 171 | 196 | var result strings.Builder | |
| 197 | + | result.Grow(len(word)) | |
| 172 | 198 | for _, r := range word { | |
| 173 | - | switch r { | |
| 174 | - | case '"', '*', '(', ')', ':', '^', '-', '/': | |
| 175 | - | // Skip FTS5 special characters | |
| 176 | - | default: | |
| 199 | + | if unicode.IsLetter(r) || unicode.IsNumber(r) { | |
| 177 | 200 | result.WriteRune(r) | |
| 178 | 201 | } | |
| 179 | 202 | } | |
| 180 | 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 | 228 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 184 | 229 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | |
| 185 | 230 | Limit: int64(limit), | |
| @@ -266,6 +311,20 @@ func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error | |||
| 266 | 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 | 328 | func (r *historyRepo) Delete(ctx context.Context, id int64) error { | |
| 270 | 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 | 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 | 461 | func TestHistoryRepository_Search_NoResults(t *testing.T) { | |
| 433 | 462 | ctx := historyTestCtx() | |
| 434 | 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 | 610 | func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) { | |
| 561 | 611 | ctx := historyTestCtx() | |
| 562 | 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 | 18 | import ( | |
| 19 | 19 | "context" | |
| 20 | 20 | "database/sql" | |
| 21 | + | "fmt" | |
| 21 | 22 | "sync" | |
| 22 | 23 | "time" | |
| 23 | 24 | ||
| @@ -114,6 +115,31 @@ func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url str | |||
| 114 | 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 | 143 | func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error { | |
| 118 | 144 | if err := r.init(ctx); err != nil { | |
| 119 | 145 | return err | |
M internal/infrastructure/persistence/sqlite/queries/history.sql +5 -0
| @@ -19,6 +19,11 @@ UPDATE history | |||
| 19 | 19 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | |
| 20 | 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 | 27 | -- name: SearchHistory :many | |
| 23 | 28 | SELECT * FROM history | |
| 24 | 29 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | |
M internal/infrastructure/persistence/sqlite/session_state_repo.go +27 -3
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "database/sql" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -13,12 +14,16 @@ import ( | |||
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | type sessionStateRepo struct { | |
| 17 | + | db *sql.DB | |
| 16 | 18 | queries *sqlc.Queries | |
| 17 | 19 | } | |
| 18 | 20 | ||
| 19 | 21 | // NewSessionStateRepository creates a new session state repository. | |
| 20 | 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 | 29 | // SaveSnapshot saves or updates a session state snapshot. | |
| @@ -40,14 +45,33 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi | |||
| 40 | 45 | Int("pane_count", state.CountPanes()). | |
| 41 | 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 | 60 | SessionID: string(state.SessionID), | |
| 45 | 61 | StateJson: string(stateJSON), | |
| 46 | 62 | Version: int64(state.Version), | |
| 47 | 63 | TabCount: int64(len(state.Tabs)), | |
| 48 | 64 | PaneCount: int64(state.CountPanes()), | |
| 49 | 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 | 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 | 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 | 429 | const SearchHistory = `-- name: SearchHistory :many | |
| 414 | 430 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 415 | 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 | 64 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | |
| 65 | 65 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | |
| 66 | 66 | IncrementVisitCount(ctx context.Context, url string) error | |
| 67 | + | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | |
| 67 | 68 | InsertSession(ctx context.Context, arg InsertSessionParams) error | |
| 68 | 69 | IsWhitelisted(ctx context.Context, domain string) (int64, error) | |
| 69 | 70 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |
M internal/infrastructure/snapshot/service.go +113 -4
| @@ -2,6 +2,9 @@ package snapshot | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 5 | 8 | "sync" | |
| 6 | 9 | "time" | |
| 7 | 10 | ||
| @@ -10,11 +13,19 @@ import ( | |||
| 10 | 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 | 22 | // Service handles debounced session state snapshots. | |
| 14 | 23 | type Service struct { | |
| 15 | 24 | snapshotUC *usecase.SnapshotSessionUseCase | |
| 16 | 25 | provider port.TabListProvider | |
| 17 | 26 | interval time.Duration | |
| 27 | + | retries int | |
| 28 | + | retryDelay time.Duration | |
| 18 | 29 | ||
| 19 | 30 | mu sync.Mutex | |
| 20 | 31 | timer *time.Timer | |
| @@ -31,12 +42,14 @@ func NewService( | |||
| 31 | 42 | intervalMs int, | |
| 32 | 43 | ) *Service { | |
| 33 | 44 | if intervalMs <= 0 { | |
| 34 | - | intervalMs = 5000 // Default 5 seconds | |
| 45 | + | intervalMs = defaultSnapshotIntervalMs | |
| 35 | 46 | } | |
| 36 | 47 | return &Service{ | |
| 37 | 48 | snapshotUC: snapshotUC, | |
| 38 | 49 | provider: provider, | |
| 39 | 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 | 67 | // to avoid FK constraint violations. | |
| 55 | 68 | func (s *Service) SetReady() { | |
| 56 | 69 | s.mu.Lock() | |
| 57 | - | defer s.mu.Unlock() | |
| 70 | + | if s.timer != nil { | |
| 71 | + | s.timer.Stop() | |
| 72 | + | s.timer = nil | |
| 73 | + | } | |
| 58 | 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 | 90 | // Stop stops the service and saves final state. | |
| @@ -135,11 +164,91 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 135 | 164 | sessionID := s.provider.GetSessionID() | |
| 136 | 165 | ||
| 137 | 166 | if sessionID == "" { | |
| 167 | + | s.markDirty() | |
| 138 | 168 | return nil | |
| 139 | 169 | } | |
| 140 | 170 | ||
| 141 | - | return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{ | |
| 171 | + | if err := s.executeWithRetry(ctx, usecase.SnapshotInput{ | |
| 142 | 172 | SessionID: sessionID, | |
| 143 | 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 | 12 | "errors" | |
| 13 | 13 | "fmt" | |
| 14 | 14 | "io" | |
| 15 | + | "math/rand" | |
| 16 | + | "net" | |
| 15 | 17 | "net/http" | |
| 16 | 18 | "net/url" | |
| 17 | 19 | "os" | |
| @@ -20,8 +22,10 @@ import ( | |||
| 20 | 22 | "runtime" | |
| 21 | 23 | "strconv" | |
| 22 | 24 | "strings" | |
| 25 | + | "syscall" | |
| 23 | 26 | "time" | |
| 24 | 27 | ||
| 28 | + | "github.com/bnema/dumber/internal/application/port" | |
| 25 | 29 | "github.com/bnema/dumber/internal/domain/entity" | |
| 26 | 30 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 27 | 31 | "github.com/bnema/dumber/internal/logging" | |
| @@ -58,6 +62,18 @@ const ( | |||
| 58 | 62 | ||
| 59 | 63 | // Allowed GitHub download host. | |
| 60 | 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 | 79 | // githubRelease represents the GitHub API response for a release. | |
| @@ -70,13 +86,17 @@ type githubRelease struct { | |||
| 70 | 86 | ||
| 71 | 87 | // GitHubChecker implements UpdateChecker using the GitHub API. | |
| 72 | 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 | 94 | // NewGitHubChecker creates a new GitHub-based update checker. | |
| 77 | 95 | func NewGitHubChecker() *GitHubChecker { | |
| 78 | 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 | 135 | req.Header.Set("Accept", "application/vnd.github.v3+json") | |
| 116 | 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 | 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 | 144 | return nil, fmt.Errorf("failed to fetch latest release: %w", err) | |
| 121 | 145 | } | |
| 122 | 146 | defer func() { _ = resp.Body.Close() }() | |
| 123 | 147 | ||
| 124 | 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 | 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 | 243 | // GitHubDownloader implements UpdateDownloader for GitHub releases. | |
| 216 | 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 | 250 | // NewGitHubDownloader creates a new GitHub release downloader. | |
| 221 | 251 | func NewGitHubDownloader() *GitHubDownloader { | |
| 222 | 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 | 392 | // validateDownloadURL ensures the URL is a valid GitHub releases URL. | |
| 228 | 393 | func validateDownloadURL(downloadURL string) error { | |
| 229 | 394 | parsed, err := url.Parse(downloadURL) | |
| @@ -257,7 +422,7 @@ func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]strin | |||
| 257 | 422 | } | |
| 258 | 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 | 426 | if err != nil { | |
| 262 | 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 | 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 | 531 | if err != nil { | |
| 367 | 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 | 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 | 595 | // sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal. | |
| 427 | 596 | func sanitizeTarPath(name, destDir string) (string, error) { | |
| 428 | 597 | // Clean the path to remove any . or .. components. | |
M internal/infrastructure/updater/github_test.go +141 -0
| @@ -1,12 +1,20 @@ | |||
| 1 | 1 | package updater | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "crypto/sha256" | |
| 5 | 6 | "encoding/hex" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 6 | 10 | "os" | |
| 7 | 11 | "path/filepath" | |
| 8 | 12 | "strings" | |
| 13 | + | "sync/atomic" | |
| 9 | 14 | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 18 | ) | |
| 11 | 19 | ||
| 12 | 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 | 485 | // contains checks if substr is in s (helper for error message checks). | |
| 359 | 486 | func contains(s, substr string) bool { | |
| 360 | 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 | 37 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | |
| 38 | 38 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | |
| 39 | 39 | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | |
| 40 | - | DataDir: dataDir, | |
| 41 | - | CacheDir: cacheDir, | |
| 40 | + | DataDir: dataDir, | |
| 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 | 74 | // Create persistent network session FIRST | |
| 73 | 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 | 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 | 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 | 118 | // Create persistent network session | |
| 117 | 119 | session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir) | |
| 118 | 120 | if session == nil { | |
| @@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error { | |||
| 158 | 160 | ||
| 159 | 161 | cookiePath := filepath.Join(c.dataDir, "cookies.db") | |
| 160 | 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 | 168 | Str("cookie_path", cookiePath). | |
| 169 | + | Str("cookie_policy", cookiePolicyLabel). | |
| 170 | + | Bool("itp_enabled", opts.ITPEnabled). | |
| 165 | 171 | Msg("cookie storage configured") | |
| 166 | 172 | ||
| 167 | 173 | // Enable persistent credential storage | |
| @@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error { | |||
| 182 | 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 | 204 | // Context returns the shared WebContext. | |
| 186 | 205 | func (c *WebKitContext) Context() *webkit.WebContext { | |
| 187 | 206 | c.mu.RLock() | |
M internal/infrastructure/webkit/messaging.go +4 -4
| @@ -47,7 +47,7 @@ type MessageRouter struct { | |||
| 47 | 47 | ||
| 48 | 48 | mu sync.RWMutex | |
| 49 | 49 | callbacks []interface{} | |
| 50 | - | signals []uint32 | |
| 50 | + | signals []uint | |
| 51 | 51 | ||
| 52 | 52 | idMu sync.Mutex | |
| 53 | 53 | syncedIDs map[WebViewID]bool | |
| @@ -121,7 +121,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal | |||
| 121 | 121 | // It registers the script message handler in the MAIN world (not isolated). | |
| 122 | 122 | // WebKit's messageHandlers is only available in main world. | |
| 123 | 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 | 125 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | |
| 126 | 126 | ||
| 127 | 127 | log.Debug().Msg("SetupMessageHandler called") | |
| @@ -146,7 +146,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||
| 146 | 146 | ||
| 147 | 147 | // Connect to "script-message-received::dumber" with handler name as signal detail | |
| 148 | 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 | 151 | r.mu.Lock() | |
| 152 | 152 | r.signals = append(r.signals, signalID) | |
| @@ -164,7 +164,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||
| 164 | 164 | log.Info(). | |
| 165 | 165 | Str("handler", MessageHandlerName). | |
| 166 | 166 | Str("world", "main"). | |
| 167 | - | Uint32("signal_id", signalID). | |
| 167 | + | Uint64("signal_id", uint64(signalID)). | |
| 168 | 168 | Msg("script message handler connected") | |
| 169 | 169 | ||
| 170 | 170 | return signalID, nil | |
M internal/infrastructure/webkit/pool.go +182 -39
| @@ -3,6 +3,8 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 6 | 8 | "sync" | |
| 7 | 9 | "sync/atomic" | |
| 8 | 10 | "time" | |
| @@ -31,6 +33,12 @@ type PoolConfig struct { | |||
| 31 | 33 | PrewarmCount int | |
| 32 | 34 | // IdleTimeout is how long idle views stay in the pool before being destroyed. | |
| 33 | 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 | 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 | 60 | // WebViewPool manages a pool of pre-created WebViews for fast tab creation. | |
| 47 | 61 | type WebViewPool struct { | |
| 48 | 62 | wkCtx *WebKitContext | |
| @@ -50,6 +64,7 @@ type WebViewPool struct { | |||
| 50 | 64 | config PoolConfig | |
| 51 | 65 | ||
| 52 | 66 | pool chan *WebView | |
| 67 | + | poolMu sync.Mutex | |
| 53 | 68 | injector *ContentInjector | |
| 54 | 69 | router *MessageRouter | |
| 55 | 70 | filterApplier FilterApplier // Optional content filter applier | |
| @@ -59,6 +74,19 @@ type WebViewPool struct { | |||
| 59 | 74 | ||
| 60 | 75 | closed atomic.Bool | |
| 61 | 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 | 92 | // NewWebViewPool creates a new WebView pool. | |
| @@ -79,21 +107,28 @@ func NewWebViewPool( | |||
| 79 | 107 | if cfg.MinSize > cfg.MaxSize { | |
| 80 | 108 | cfg.MinSize = cfg.MaxSize | |
| 81 | 109 | } | |
| 110 | + | cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy) | |
| 111 | + | cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy) | |
| 82 | 112 | if ctx == nil { | |
| 83 | 113 | ctx = context.Background() | |
| 84 | 114 | } | |
| 85 | 115 | ||
| 86 | 116 | p := &WebViewPool{ | |
| 87 | - | wkCtx: wkCtx, | |
| 88 | - | settings: settings, | |
| 89 | - | config: cfg, | |
| 90 | - | pool: make(chan *WebView, cfg.MaxSize), | |
| 91 | - | injector: injector, | |
| 92 | - | router: router, | |
| 117 | + | wkCtx: wkCtx, | |
| 118 | + | settings: settings, | |
| 119 | + | config: cfg, | |
| 120 | + | pool: make(chan *WebView, cfg.MaxSize), | |
| 121 | + | injector: injector, | |
| 122 | + | router: router, | |
| 123 | + | reusePolicy: cfg.ReusePolicy, | |
| 124 | + | terminatePolicy: cfg.TerminatePolicy, | |
| 93 | 125 | } | |
| 94 | 126 | ||
| 95 | 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 | 133 | return p | |
| 99 | 134 | } | |
| @@ -113,6 +148,7 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |||
| 113 | 148 | // Acquire gets a WebView from the pool or creates a new one. | |
| 114 | 149 | func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |
| 115 | 150 | log := logging.FromContext(ctx) | |
| 151 | + | start := time.Now() | |
| 116 | 152 | ||
| 117 | 153 | if p.closed.Load() { | |
| 118 | 154 | return nil, ErrPoolClosed | |
| @@ -135,7 +171,15 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 135 | 171 | if p.filterApplier != nil { | |
| 136 | 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 | 183 | return wv, nil | |
| 140 | 184 | } | |
| 141 | 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 | 190 | // Pool empty or pooled view was destroyed, create new | |
| 147 | 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 | 216 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |
| @@ -174,29 +239,52 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |||
| 174 | 239 | return wv, nil | |
| 175 | 240 | } | |
| 176 | 241 | ||
| 177 | - | // Release returns a WebView to the pool. | |
| 178 | - | // For safety, we always destroy and never pool WebViews that have been used. | |
| 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. | |
| 242 | + | // Release returns a WebView to the pool when configured and safe. | |
| 243 | + | // Default behavior remains conservative (`reuse_policy=off`) and destroys on release. | |
| 181 | 244 | func (p *WebViewPool) Release(ctx context.Context, wv *WebView) { | |
| 182 | 245 | log := logging.FromContext(ctx) | |
| 246 | + | start := time.Now() | |
| 183 | 247 | ||
| 184 | 248 | if wv == nil || wv.IsDestroyed() || p.closed.Load() { | |
| 185 | 249 | if wv != nil && !wv.IsDestroyed() { | |
| 186 | - | wv.Destroy() | |
| 250 | + | wv.DestroyWithPolicy(p.terminatePolicy) | |
| 187 | 251 | } | |
| 252 | + | p.releaseDropped.Add(1) | |
| 188 | 253 | return | |
| 189 | 254 | } | |
| 190 | 255 | ||
| 191 | - | // CRITICAL: Always destroy used WebViews instead of pooling them. | |
| 192 | - | // GLib signals are connected with closures that can fire asynchronously | |
| 193 | - | // even after we clear Go callbacks. Loading about:blank triggers signals | |
| 194 | - | // that can crash if the widget tree has been modified. | |
| 195 | - | // | |
| 196 | - | // The pool is designed for prewarmed WebViews only, not for recycling | |
| 197 | - | // WebViews that have been attached to the UI. | |
| 198 | - | log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)") | |
| 199 | - | wv.Destroy() | |
| 256 | + | action := "destroyed" | |
| 257 | + | if p.shouldReuseOnRelease(wv) { | |
| 258 | + | wv.ResetForPoolReuse() | |
| 259 | + | if p.tryPut(ctx, wv) { | |
| 260 | + | action = "reused" | |
| 261 | + | p.releaseReused.Add(1) | |
| 262 | + | } else { | |
| 263 | + | action = "destroyed_after_reuse_failed" | |
| 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 | 290 | // PrewarmFirst creates exactly one WebView synchronously. | |
| @@ -218,12 +306,11 @@ func (p *WebViewPool) PrewarmFirst(ctx context.Context) error { | |||
| 218 | 306 | return err | |
| 219 | 307 | } | |
| 220 | 308 | ||
| 221 | - | select { | |
| 222 | - | case p.pool <- wv: | |
| 309 | + | if p.tryPut(ctx, wv) { | |
| 223 | 310 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start") | |
| 224 | - | default: | |
| 225 | - | wv.Destroy() // Pool somehow full (shouldn't happen) | |
| 311 | + | return nil | |
| 226 | 312 | } | |
| 313 | + | wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen) | |
| 227 | 314 | return nil | |
| 228 | 315 | } | |
| 229 | 316 | ||
| @@ -294,11 +381,10 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { | |||
| 294 | 381 | if err != nil { | |
| 295 | 382 | log.Warn().Err(err).Msg("failed to prewarm webview") | |
| 296 | 383 | } else { | |
| 297 | - | select { | |
| 298 | - | case p.pool <- wv: | |
| 384 | + | if p.tryPut(ctx, wv) { | |
| 299 | 385 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | |
| 300 | - | default: | |
| 301 | - | wv.Destroy() | |
| 386 | + | } else { | |
| 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 | 424 | // Try to add to pool | |
| 339 | - | select { | |
| 340 | - | case p.pool <- wv: | |
| 425 | + | if p.tryPut(ctx, wv) { | |
| 341 | 426 | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | |
| 342 | - | default: | |
| 427 | + | } else { | |
| 343 | 428 | // Pool full, destroy | |
| 344 | - | wv.Destroy() | |
| 429 | + | wv.DestroyWithPolicy(p.terminatePolicy) | |
| 345 | 430 | } | |
| 346 | 431 | ||
| 347 | 432 | // Small delay between creations to avoid overwhelming the system | |
| @@ -387,11 +472,10 @@ refreshLoop: | |||
| 387 | 472 | } | |
| 388 | 473 | ||
| 389 | 474 | // Put back in pool | |
| 390 | - | select { | |
| 391 | - | case p.pool <- wv: | |
| 475 | + | if p.tryPut(ctx, wv) { | |
| 392 | 476 | refreshed++ | |
| 393 | - | default: | |
| 394 | - | wv.Destroy() | |
| 477 | + | } else { | |
| 478 | + | wv.DestroyWithPolicy(p.terminatePolicy) | |
| 395 | 479 | } | |
| 396 | 480 | default: | |
| 397 | 481 | // Pool was modified during iteration, stop | |
| @@ -417,7 +501,9 @@ func (p *WebViewPool) Close(ctx context.Context) { | |||
| 417 | 501 | p.wg.Wait() | |
| 418 | 502 | ||
| 419 | 503 | // Drain and destroy all pooled views | |
| 504 | + | p.poolMu.Lock() | |
| 420 | 505 | close(p.pool) | |
| 506 | + | p.poolMu.Unlock() | |
| 421 | 507 | for wv := range p.pool { | |
| 422 | 508 | if wv != nil && !wv.IsDestroyed() { | |
| 423 | 509 | wv.Destroy() | |
| @@ -426,3 +512,60 @@ func (p *WebViewPool) Close(ctx context.Context) { | |||
| 426 | 512 | ||
| 427 | 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 | 5 | "embed" | |
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "fmt" | |
| 8 | + | "html" | |
| 8 | 9 | "io/fs" | |
| 9 | 10 | "mime" | |
| 10 | 11 | "net/http" | |
| @@ -28,7 +29,9 @@ const ( | |||
| 28 | 29 | HomePath = "home" | |
| 29 | 30 | ConfigPath = "config" | |
| 30 | 31 | ErrorPath = "error" | |
| 32 | + | CrashPath = "crash" | |
| 31 | 33 | IndexHTML = "index.html" | |
| 34 | + | httpGET = "GET" | |
| 32 | 35 | ) | |
| 33 | 36 | ||
| 34 | 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 | 180 | // API: Get current config (used by dumb://config) | |
| 165 | 181 | h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 166 | - | if req.Method != "GET" { | |
| 182 | + | if req.Method != "" && req.Method != httpGET { | |
| 167 | 183 | return nil | |
| 168 | 184 | } | |
| 169 | 185 | ||
| @@ -172,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||
| 172 | 188 | ||
| 173 | 189 | // API: Get default config (used by Reset Defaults in dumb://config) | |
| 174 | 190 | h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |
| 175 | - | if req.Method != "GET" { | |
| 191 | + | if req.Method != "" && req.Method != httpGET { | |
| 176 | 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 | 338 | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | |
| 184 | 339 | // Get hardware info for display and profile resolution | |
| 185 | 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 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "net/url" | |
| 7 | + | "os" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "sync/atomic" | |
| @@ -135,39 +136,59 @@ type WebView struct { | |||
| 135 | 136 | lastProgressUpdate atomic.Int64 // Unix nanoseconds | |
| 136 | 137 | ||
| 137 | 138 | // Signal handler IDs for disconnection | |
| 138 | - | signalIDs []uint32 | |
| 139 | + | signalIDs []uintptr | |
| 139 | 140 | ||
| 140 | 141 | // Callbacks (set by UI layer) | |
| 141 | - | OnLoadChanged func(LoadEvent) | |
| 142 | - | OnTitleChanged func(string) | |
| 143 | - | OnURIChanged func(string) | |
| 144 | - | OnProgressChanged func(float64) | |
| 145 | - | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes | |
| 146 | - | OnClose func() | |
| 147 | - | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | |
| 148 | - | OnReadyToShow func() // Called when popup is ready to display | |
| 149 | - | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | |
| 150 | - | OnEnterFullscreen func() bool // Return true to prevent fullscreen | |
| 151 | - | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | |
| 152 | - | 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) | |
| 142 | + | OnLoadChanged func(LoadEvent) | |
| 143 | + | OnTitleChanged func(string) | |
| 144 | + | OnURIChanged func(string) | |
| 145 | + | OnProgressChanged func(float64) | |
| 146 | + | OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes | |
| 147 | + | OnClose func() | |
| 148 | + | OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup | |
| 149 | + | OnReadyToShow func() // Called when popup is ready to display | |
| 150 | + | OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation) | |
| 151 | + | OnEnterFullscreen func() bool // Return true to prevent fullscreen | |
| 152 | + | OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen | |
| 153 | + | OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops | |
| 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 | 157 | logger zerolog.Logger | |
| 156 | 158 | mu sync.RWMutex | |
| 157 | 159 | ||
| 158 | 160 | frontendAttached atomic.Bool | |
| 161 | + | navigationActive atomic.Bool | |
| 159 | 162 | ||
| 160 | 163 | // asyncCallbacks keeps references to async JS callbacks to prevent GC | |
| 161 | 164 | asyncCallbacks []interface{} | |
| 162 | 165 | ||
| 166 | + | // runJSErrorStats aggregates repeated non-fatal RunJavaScript errors by domain+signature. | |
| 167 | + | runJSErrorStats map[string]runJSErrorStat | |
| 168 | + | ||
| 163 | 169 | // findController is cached to prevent GC from collecting the Go wrapper | |
| 164 | 170 | findController *findControllerAdapter | |
| 165 | 171 | findControllerOnce sync.Once | |
| 166 | 172 | ||
| 167 | 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 | 192 | type findControllerAdapter struct { | |
| 172 | 193 | fc *webkit.FindController | |
| 173 | 194 | } | |
| @@ -234,7 +255,7 @@ func (a *findControllerAdapter) GetSearchText() string { | |||
| 234 | 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 | 259 | if a == nil || a.fc == nil || callback == nil { | |
| 239 | 260 | return 0 | |
| 240 | 261 | } | |
| @@ -244,7 +265,7 @@ func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint | |||
| 244 | 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 | 269 | if a == nil || a.fc == nil || callback == nil { | |
| 249 | 270 | return 0 | |
| 250 | 271 | } | |
| @@ -254,7 +275,7 @@ func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 { | |||
| 254 | 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 | 279 | if a == nil || a.fc == nil || callback == nil { | |
| 259 | 280 | return 0 | |
| 260 | 281 | } | |
| @@ -264,7 +285,7 @@ func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) | |||
| 264 | 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 | 289 | if a == nil || a.fc == nil || id == 0 { | |
| 269 | 290 | return | |
| 270 | 291 | } | |
| @@ -300,10 +321,11 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||
| 300 | 321 | } | |
| 301 | 322 | ||
| 302 | 323 | wv := &WebView{ | |
| 303 | - | inner: inner, | |
| 304 | - | ucm: inner.GetUserContentManager(), | |
| 305 | - | logger: log.With().Str("component", "webview").Logger(), | |
| 306 | - | signalIDs: make([]uint32, 0, 4), | |
| 324 | + | inner: inner, | |
| 325 | + | ucm: inner.GetUserContentManager(), | |
| 326 | + | logger: log.With().Str("component", "webview").Logger(), | |
| 327 | + | signalIDs: make([]uintptr, 0, 4), | |
| 328 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |
| 307 | 329 | } | |
| 308 | 330 | ||
| 309 | 331 | // Register in global registry | |
| @@ -349,11 +371,12 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti | |||
| 349 | 371 | Msg("related webview created, checking pointers") | |
| 350 | 372 | ||
| 351 | 373 | wv := &WebView{ | |
| 352 | - | inner: inner, | |
| 353 | - | isRelated: true, // Shares web process with parent - must not terminate process on destroy | |
| 354 | - | ucm: inner.GetUserContentManager(), | |
| 355 | - | logger: log.With().Str("component", "webview-popup").Logger(), | |
| 356 | - | signalIDs: make([]uint32, 0, 6), | |
| 374 | + | inner: inner, | |
| 375 | + | isRelated: true, // Shares web process with parent - must not terminate process on destroy | |
| 376 | + | ucm: inner.GetUserContentManager(), | |
| 377 | + | logger: log.With().Str("component", "webview-popup").Logger(), | |
| 378 | + | signalIDs: make([]uintptr, 0, 6), | |
| 379 | + | runJSErrorStats: make(map[string]runJSErrorStat), | |
| 357 | 380 | } | |
| 358 | 381 | ||
| 359 | 382 | wv.id = globalRegistry.register(wv) | |
| @@ -388,6 +411,7 @@ func (wv *WebView) connectSignals() { | |||
| 388 | 411 | wv.connectAudioStateSignal() | |
| 389 | 412 | wv.connectMouseTargetChangedSignal() | |
| 390 | 413 | wv.connectBackForwardListChangedSignal() | |
| 414 | + | wv.connectWebProcessTerminatedSignal() | |
| 391 | 415 | } | |
| 392 | 416 | ||
| 393 | 417 | func (wv *WebView) connectLoadChangedSignal() { | |
| @@ -404,6 +428,7 @@ func (wv *WebView) connectLoadChangedSignal() { | |||
| 404 | 428 | ||
| 405 | 429 | switch event { | |
| 406 | 430 | case webkit.LoadStartedValue: | |
| 431 | + | wv.navigationActive.Store(true) | |
| 407 | 432 | wv.isLoading = true | |
| 408 | 433 | wv.logger.Debug().Str("uri", uri).Msg("load started") | |
| 409 | 434 | case webkit.LoadRedirectedValue: | |
| @@ -421,7 +446,7 @@ func (wv *WebView) connectLoadChangedSignal() { | |||
| 421 | 446 | } | |
| 422 | 447 | } | |
| 423 | 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 | 452 | func (wv *WebView) connectCloseSignal() { | |
| @@ -431,7 +456,7 @@ func (wv *WebView) connectCloseSignal() { | |||
| 431 | 456 | } | |
| 432 | 457 | } | |
| 433 | 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 | 462 | func (wv *WebView) connectCreateSignal() { | |
| @@ -473,7 +498,7 @@ func (wv *WebView) connectCreateSignal() { | |||
| 473 | 498 | return newWV.inner.Widget | |
| 474 | 499 | } | |
| 475 | 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 | 504 | func (wv *WebView) connectReadyToShowSignal() { | |
| @@ -483,7 +508,7 @@ func (wv *WebView) connectReadyToShowSignal() { | |||
| 483 | 508 | } | |
| 484 | 509 | } | |
| 485 | 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 | 514 | func (wv *WebView) connectTitleSignal() { | |
| @@ -498,7 +523,7 @@ func (wv *WebView) connectTitleSignal() { | |||
| 498 | 523 | } | |
| 499 | 524 | } | |
| 500 | 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 | 529 | func (wv *WebView) connectURISignal() { | |
| @@ -514,7 +539,7 @@ func (wv *WebView) connectURISignal() { | |||
| 514 | 539 | } | |
| 515 | 540 | } | |
| 516 | 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 | 545 | func (wv *WebView) connectFaviconSignal() { | |
| @@ -525,7 +550,7 @@ func (wv *WebView) connectFaviconSignal() { | |||
| 525 | 550 | } | |
| 526 | 551 | } | |
| 527 | 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 | 556 | // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead. | |
| @@ -555,7 +580,7 @@ func (wv *WebView) connectProgressSignal() { | |||
| 555 | 580 | } | |
| 556 | 581 | } | |
| 557 | 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 | 586 | func (wv *WebView) connectDecidePolicySignal() { | |
| @@ -571,7 +596,7 @@ func (wv *WebView) connectDecidePolicySignal() { | |||
| 571 | 596 | } | |
| 572 | 597 | } | |
| 573 | 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 | 602 | // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads). | |
| @@ -770,7 +795,7 @@ func (wv *WebView) connectEnterFullscreenSignal() { | |||
| 770 | 795 | return false // Allow fullscreen | |
| 771 | 796 | } | |
| 772 | 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 | 801 | func (wv *WebView) connectLeaveFullscreenSignal() { | |
| @@ -783,7 +808,7 @@ func (wv *WebView) connectLeaveFullscreenSignal() { | |||
| 783 | 808 | return false // Allow leaving fullscreen | |
| 784 | 809 | } | |
| 785 | 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 | 814 | func (wv *WebView) connectAudioStateSignal() { | |
| @@ -801,7 +826,7 @@ func (wv *WebView) connectAudioStateSignal() { | |||
| 801 | 826 | } | |
| 802 | 827 | } | |
| 803 | 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 | 832 | func (wv *WebView) connectMouseTargetChangedSignal() { | |
| @@ -829,7 +854,7 @@ func (wv *WebView) connectMouseTargetChangedSignal() { | |||
| 829 | 854 | wv.OnLinkHover(uri) | |
| 830 | 855 | } | |
| 831 | 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 | 860 | func (wv *WebView) connectBackForwardListChangedSignal() { | |
| @@ -857,7 +882,52 @@ func (wv *WebView) connectBackForwardListChangedSignal() { | |||
| 857 | 882 | } | |
| 858 | 883 | sigID := backForwardList.ConnectChanged(&changedCb) | |
| 859 | 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 | 933 | // ID returns the unique identifier for this WebView. | |
| @@ -907,6 +977,7 @@ func (wv *WebView) LoadURI(ctx context.Context, uri string) error { | |||
| 907 | 977 | if wv.destroyed.Load() { | |
| 908 | 978 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 909 | 979 | } | |
| 980 | + | wv.navigationActive.Store(true) | |
| 910 | 981 | wv.inner.LoadUri(uri) | |
| 911 | 982 | logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI") | |
| 912 | 983 | return nil | |
| @@ -917,6 +988,7 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error | |||
| 917 | 988 | if wv.destroyed.Load() { | |
| 918 | 989 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 919 | 990 | } | |
| 991 | + | wv.navigationActive.Store(true) | |
| 920 | 992 | var baseURIPtr *string | |
| 921 | 993 | if baseURI != "" { | |
| 922 | 994 | baseURIPtr = &baseURI | |
| @@ -1140,6 +1212,8 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1140 | 1212 | wv.OnFaviconChanged = nil | |
| 1141 | 1213 | wv.OnClose = nil | |
| 1142 | 1214 | wv.OnCreate = nil | |
| 1215 | + | wv.OnLinkHover = nil | |
| 1216 | + | wv.OnWebProcessTerminated = nil | |
| 1143 | 1217 | return | |
| 1144 | 1218 | } | |
| 1145 | 1219 | ||
| @@ -1179,6 +1253,13 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1179 | 1253 | } | |
| 1180 | 1254 | } | |
| 1181 | 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 | 1265 | // ShowDevTools opens the WebKit inspector/developer tools. | |
| @@ -1215,6 +1296,16 @@ func (wv *WebView) IsDestroyed() bool { | |||
| 1215 | 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 | 1309 | // Close triggers the close callback as if window.close() was called. | |
| 1219 | 1310 | // This is used for programmatic popup closure (e.g., OAuth auto-close). | |
| 1220 | 1311 | func (wv *WebView) Close() { | |
| @@ -1236,13 +1327,13 @@ func (wv *WebView) DisconnectSignals() { | |||
| 1236 | 1327 | ||
| 1237 | 1328 | obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer()) | |
| 1238 | 1329 | for _, sigID := range wv.signalIDs { | |
| 1239 | - | gobject.SignalHandlerDisconnect(obj, sigID) | |
| 1330 | + | gobject.SignalHandlerDisconnect(obj, uint(sigID)) | |
| 1240 | 1331 | } | |
| 1241 | 1332 | wv.signalIDs = wv.signalIDs[:0] // Clear the slice | |
| 1242 | 1333 | ||
| 1243 | 1334 | if wv.backForwardList != nil && wv.backForwardListSignalID != 0 { | |
| 1244 | 1335 | bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer()) | |
| 1245 | - | gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID) | |
| 1336 | + | gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID)) | |
| 1246 | 1337 | wv.backForwardListSignalID = 0 | |
| 1247 | 1338 | wv.backForwardList = nil | |
| 1248 | 1339 | } | |
| @@ -1254,9 +1345,16 @@ func (wv *WebView) DisconnectSignals() { | |||
| 1254 | 1345 | // This must be called when a WebView is no longer needed to free GPU resources, | |
| 1255 | 1346 | // VA-API decoder contexts, and DMA-BUF buffers held by the web process. | |
| 1256 | 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 | 1354 | if wv.destroyed.Swap(true) { | |
| 1258 | 1355 | return // Already destroyed | |
| 1259 | 1356 | } | |
| 1357 | + | policy = resolveTerminatePolicy(policy) | |
| 1260 | 1358 | ||
| 1261 | 1359 | // 1. Disconnect all signal handlers first to prevent callbacks during cleanup | |
| 1262 | 1360 | wv.DisconnectSignals() | |
| @@ -1275,6 +1373,7 @@ func (wv *WebView) Destroy() { | |||
| 1275 | 1373 | wv.OnLeaveFullscreen = nil | |
| 1276 | 1374 | wv.OnAudioStateChanged = nil | |
| 1277 | 1375 | wv.OnLinkHover = nil | |
| 1376 | + | wv.OnWebProcessTerminated = nil | |
| 1278 | 1377 | ||
| 1279 | 1378 | // 3. Clear async callback references | |
| 1280 | 1379 | wv.mu.Lock() | |
| @@ -1287,11 +1386,9 @@ func (wv *WebView) Destroy() { | |||
| 1287 | 1386 | wv.inner.Unparent() | |
| 1288 | 1387 | } | |
| 1289 | 1388 | ||
| 1290 | - | // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts) | |
| 1291 | - | // This is critical to prevent zombie processes that hold video decoder resources. | |
| 1292 | - | // IMPORTANT: Skip for related views (popups) - they share the web process with their | |
| 1293 | - | // parent. Terminating the shared process would kill the parent WebView too! | |
| 1294 | - | if !wv.isRelated && wv.inner != nil { | |
| 1389 | + | // 5. Optionally terminate web process (skip for related popup views sharing parent process). | |
| 1390 | + | terminate := shouldTerminateWebProcess(policy, wv.isRelated, wv.navigationActive.Load()) | |
| 1391 | + | if terminate && wv.inner != nil { | |
| 1295 | 1392 | wv.inner.TerminateWebProcess() | |
| 1296 | 1393 | } | |
| 1297 | 1394 | ||
| @@ -1303,13 +1400,176 @@ func (wv *WebView) Destroy() { | |||
| 1303 | 1400 | wv.ucm = nil | |
| 1304 | 1401 | wv.findController = nil | |
| 1305 | 1402 | ||
| 1306 | - | if wv.isRelated { | |
| 1307 | - | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("related webview destroyed (process shared with parent)") | |
| 1308 | - | } else { | |
| 1309 | - | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated") | |
| 1403 | + | wv.logger.Debug(). | |
| 1404 | + | Uint64("id", uint64(wv.id)). | |
| 1405 | + | Bool("related", wv.isRelated). | |
| 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 | 1573 | // RunJavaScript executes script in the specified world (empty for main world). | |
| 1314 | 1574 | // This is fire-and-forget: it does not block and errors are logged asynchronously. | |
| 1315 | 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 | 1584 | log := logging.FromContext(ctx) | |
| 1325 | 1585 | ||
| 1326 | 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 | 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 | 1609 | return | |
| 1330 | 1610 | } | |
| 1331 | 1611 | ||
| 1332 | 1612 | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 1333 | - | value, err := wv.inner.EvaluateJavascriptFinish(res) | |
| 1613 | + | value, err := inner.EvaluateJavascriptFinish(res) | |
| 1334 | 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 | 1631 | return | |
| 1337 | 1632 | } | |
| 1338 | 1633 | ||
| 1339 | 1634 | if value != nil { | |
| 1340 | 1635 | if jscCtx := value.GetContext(); jscCtx != nil { | |
| 1341 | 1636 | if exc := jscCtx.GetException(); exc != nil { | |
| 1342 | - | log.Warn(). | |
| 1343 | - | Str("exception", exc.GetMessage()). | |
| 1344 | - | Uint64("webview_id", uint64(wv.id)). | |
| 1345 | - | Msg("RunJavaScript: JS exception") | |
| 1637 | + | exceptionMessage := strings.TrimSpace(exc.GetMessage()) | |
| 1638 | + | signature := "js_exception:" + normalizeRunJSErrorSignature(exceptionMessage) | |
| 1639 | + | shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now()) | |
| 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 | 1662 | if worldName != "" { | |
| 1360 | 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 | 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 | 12 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 13 | 13 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 14 | 14 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 15 | + | "github.com/rs/zerolog" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // FaviconAdapter bridges the domain FaviconService to GTK by providing | |
| @@ -21,8 +22,12 @@ type FaviconAdapter struct { | |||
| 21 | 22 | faviconDB *webkit.FaviconDatabase | |
| 22 | 23 | textureCache map[string]*gdk.Texture | |
| 23 | 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 | 31 | // NewFaviconAdapter creates a new FaviconAdapter. | |
| 27 | 32 | // The faviconDB can be nil if WebKit favicon database is not available. | |
| 28 | 33 | func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter { | |
| @@ -30,6 +35,7 @@ func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDataba | |||
| 30 | 35 | service: service, | |
| 31 | 36 | faviconDB: faviconDB, | |
| 32 | 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 | 201 | // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking. | |
| 196 | 202 | func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) { | |
| 197 | 203 | log := logging.FromContext(ctx) | |
| 204 | + | cacheDirWarnKey := "cache-dir" | |
| 205 | + | savePNGWarnKey := "save-png:" + domain | |
| 206 | + | sizedPNGWarnKey := "sized-png:" + domain | |
| 198 | 207 | ||
| 199 | 208 | // Check if PNG save is needed before scheduling idle callback | |
| 200 | 209 | pngPath := a.service.DiskPathPNG(domain) | |
| @@ -203,8 +212,12 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||
| 203 | 212 | if needsPNGSave { | |
| 204 | 213 | // Ensure cache directory exists (cheap check, do it now) | |
| 205 | 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 | 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 | 226 | // texture.SaveToPng must run on main thread but we defer it to avoid blocking | |
| 214 | 227 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 215 | 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 | 232 | } else { | |
| 233 | + | a.clearWarningDedup(savePNGWarnKey) | |
| 218 | 234 | log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG") | |
| 219 | 235 | ||
| 220 | 236 | // Create normalized sized copy for dmenu/fuzzel (async) | |
| 221 | 237 | go func() { | |
| 222 | 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 | 242 | } else { | |
| 243 | + | a.clearWarningDedup(sizedPNGWarnKey) | |
| 225 | 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 | 337 | if hasPNG && !hasSized { | |
| 319 | 338 | log.Debug().Str("domain", domain).Msg("creating sized PNG") | |
| 320 | 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 | 343 | } else { | |
| 344 | + | a.clearWarningDedup("sized-png:" + domain) | |
| 323 | 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 | 388 | // getOrCreateLogoTexture returns the app logo texture, creating it if needed. | |
| 329 | 389 | // The texture is cached under the InternalDomain key. | |
| 330 | 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 | 1393 | a.deps.NavigateUC, | |
| 1394 | 1394 | a.contentCoord, | |
| 1395 | 1395 | ) | |
| 1396 | + | a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) { | |
| 1397 | + | a.navCoord.ClearPaneHistory(paneID) | |
| 1398 | + | }) | |
| 1396 | 1399 | ||
| 1397 | 1400 | // Wire title updates to history persistence | |
| 1398 | 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 | 1 | package component | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/dumber/internal/ui/layout" | |
| 5 | 7 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | // LoadingSkeleton displays a themed loading placeholder. | |
| 9 | 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 | 13 | type LoadingSkeleton struct { | |
| 12 | 14 | container layout.BoxWidget | |
| 13 | 15 | content layout.BoxWidget | |
| 14 | 16 | spinner layout.SpinnerWidget | |
| 15 | 17 | logo layout.ImageWidget | |
| 18 | + | version layout.LabelWidget | |
| 16 | 19 | } | |
| 17 | 20 | ||
| 18 | 21 | const ( | |
| 19 | - | loadingSkeletonSpacing = 6 | |
| 20 | - | loadingSkeletonLogoSize = 512 | |
| 22 | + | loadingSkeletonSpacing = 6 | |
| 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 | 36 | func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |
| 24 | 37 | container := factory.NewBox(layout.OrientationVertical, 0) | |
| 25 | 38 | container.SetHexpand(true) | |
| @@ -37,14 +50,6 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |||
| 37 | 50 | content.SetCanTarget(false) | |
| 38 | 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 | 53 | // Faded app logo | |
| 49 | 54 | logo := factory.NewImage() | |
| 50 | 55 | logo.SetHalign(gtk.AlignCenterValue) | |
| @@ -60,9 +65,33 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |||
| 60 | 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 | 92 | content.Append(logo) | |
| 65 | 93 | content.Append(spinner) | |
| 94 | + | content.Append(versionLabel) | |
| 66 | 95 | container.Append(content) | |
| 67 | 96 | ||
| 68 | 97 | ls := &LoadingSkeleton{ | |
| @@ -70,6 +99,7 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton { | |||
| 70 | 99 | content: content, | |
| 71 | 100 | spinner: spinner, | |
| 72 | 101 | logo: logo, | |
| 102 | + | version: versionLabel, | |
| 73 | 103 | } | |
| 74 | 104 | ls.SetVisible(true) | |
| 75 | 105 | return ls | |
M internal/ui/component/loading_skeleton_test.go +42 -20
| @@ -17,6 +17,7 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) { | |||
| 17 | 17 | mockContent := mocks.NewMockBoxWidget(t) | |
| 18 | 18 | mockSpinner := mocks.NewMockSpinnerWidget(t) | |
| 19 | 19 | mockLogo := mocks.NewMockImageWidget(t) | |
| 20 | + | mockVersion := mocks.NewMockLabelWidget(t) | |
| 20 | 21 | ||
| 21 | 22 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | |
| 22 | 23 | mockContainer.EXPECT().SetHexpand(true).Once() | |
| @@ -34,26 +35,36 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) { | |||
| 34 | 35 | mockContent.EXPECT().SetCanTarget(false).Once() | |
| 35 | 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 | 38 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | |
| 46 | 39 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | |
| 47 | 40 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | |
| 48 | 41 | mockLogo.EXPECT().SetCanFocus(false).Once() | |
| 49 | 42 | mockLogo.EXPECT().SetCanTarget(false).Once() | |
| 50 | - | mockLogo.EXPECT().SetSizeRequest(512, 512).Once() | |
| 51 | - | mockLogo.EXPECT().SetPixelSize(512).Once() | |
| 43 | + | mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() | |
| 44 | + | mockLogo.EXPECT().SetPixelSize(mock.Anything).Once() | |
| 52 | 45 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | |
| 53 | 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 | 65 | mockContent.EXPECT().Append(mockLogo).Once() | |
| 56 | 66 | mockContent.EXPECT().Append(mockSpinner).Once() | |
| 67 | + | mockContent.EXPECT().Append(mockVersion).Once() | |
| 57 | 68 | mockContainer.EXPECT().Append(mockContent).Once() | |
| 58 | 69 | ||
| 59 | 70 | // NewLoadingSkeleton ends by calling SetVisible(true). | |
| @@ -70,6 +81,7 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) { | |||
| 70 | 81 | mockContent := mocks.NewMockBoxWidget(t) | |
| 71 | 82 | mockSpinner := mocks.NewMockSpinnerWidget(t) | |
| 72 | 83 | mockLogo := mocks.NewMockImageWidget(t) | |
| 84 | + | mockVersion := mocks.NewMockLabelWidget(t) | |
| 73 | 85 | ||
| 74 | 86 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | |
| 75 | 87 | mockContainer.EXPECT().SetHexpand(true).Once() | |
| @@ -87,26 +99,36 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) { | |||
| 87 | 99 | mockContent.EXPECT().SetCanTarget(false).Once() | |
| 88 | 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 | 102 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | |
| 99 | 103 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | |
| 100 | 104 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | |
| 101 | 105 | mockLogo.EXPECT().SetCanFocus(false).Once() | |
| 102 | 106 | mockLogo.EXPECT().SetCanTarget(false).Once() | |
| 103 | - | mockLogo.EXPECT().SetSizeRequest(512, 512).Once() | |
| 104 | - | mockLogo.EXPECT().SetPixelSize(512).Once() | |
| 107 | + | mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() | |
| 108 | + | mockLogo.EXPECT().SetPixelSize(mock.Anything).Once() | |
| 105 | 109 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | |
| 106 | 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 | 129 | mockContent.EXPECT().Append(mockLogo).Once() | |
| 109 | 130 | mockContent.EXPECT().Append(mockSpinner).Once() | |
| 131 | + | mockContent.EXPECT().Append(mockVersion).Once() | |
| 110 | 132 | mockContainer.EXPECT().Append(mockContent).Once() | |
| 111 | 133 | ||
| 112 | 134 | mockContainer.EXPECT().SetVisible(true).Once() | |
M internal/ui/component/omnibox.go +220 -209
| @@ -3,7 +3,7 @@ package component | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "math" | |
| 6 | + | stdurl "net/url" | |
| 7 | 7 | "strings" | |
| 8 | 8 | "sync" | |
| 9 | 9 | "time" | |
| @@ -21,15 +21,12 @@ import ( | |||
| 21 | 21 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 22 | 22 | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 23 | 23 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 24 | - | "github.com/jwijenbergh/puregotk/v4/pango" | |
| 25 | 24 | ) | |
| 26 | 25 | ||
| 27 | 26 | const ( | |
| 28 | 27 | debounceDelayMs = 50 | |
| 29 | 28 | endBoxSpacing = 6 | |
| 30 | 29 | defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)" | |
| 31 | - | ghostPositionRetryMs = 16 | |
| 32 | - | ghostPositionMaxAttempts = 5 | |
| 33 | 30 | minGhostInputLength = 1 | |
| 34 | 31 | ) | |
| 35 | 32 | ||
| @@ -76,8 +73,6 @@ type Omnibox struct { | |||
| 76 | 73 | zoomLabel *gtk.Label | |
| 77 | 74 | bangBadge *gtk.Label | |
| 78 | 75 | entry *gtk.SearchEntry | |
| 79 | - | entryOverlay *gtk.Overlay // Overlay for ghost text | |
| 80 | - | ghostLabel *gtk.Label // Ghost text completion label | |
| 81 | 76 | scrolledWin *gtk.ScrolledWindow | |
| 82 | 77 | listBox *gtk.ListBox | |
| 83 | 78 | ||
| @@ -123,6 +118,8 @@ type Omnibox struct { | |||
| 123 | 118 | debounceTimer *time.Timer | |
| 124 | 119 | debounceMu sync.Mutex | |
| 125 | 120 | lastQuery string // Prevent duplicate searches | |
| 121 | + | searchToken uint64 | |
| 122 | + | ghostToken uint64 | |
| 126 | 123 | ||
| 127 | 124 | // Scaling | |
| 128 | 125 | uiScale float64 | |
| @@ -512,30 +509,6 @@ func (o *Omnibox) initEntry() error { | |||
| 512 | 509 | placeholder := defaultOmniboxPlaceholder | |
| 513 | 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 | 512 | return nil | |
| 540 | 513 | } | |
| 541 | 514 | ||
| @@ -636,7 +609,7 @@ func (o *Omnibox) assembleWidgets() { | |||
| 636 | 609 | ||
| 637 | 610 | o.scrolledWin.SetChild(&o.listBox.Widget) | |
| 638 | 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 | 613 | o.mainBox.Append(&o.scrolledWin.Widget) | |
| 641 | 614 | o.outerBox.Append(&o.mainBox.Widget) | |
| 642 | 615 | } | |
| @@ -825,6 +798,10 @@ func (o *Omnibox) onEntryChanged() { | |||
| 825 | 798 | return | |
| 826 | 799 | } | |
| 827 | 800 | ||
| 801 | + | if o.isGhostProgrammaticEcho(entryText) { | |
| 802 | + | return | |
| 803 | + | } | |
| 804 | + | ||
| 828 | 805 | // Reset navigation state when user types - space should type, not toggle favorite | |
| 829 | 806 | o.mu.Lock() | |
| 830 | 807 | o.hasNavigated = false | |
| @@ -850,169 +827,129 @@ func (o *Omnibox) onEntryChanged() { | |||
| 850 | 827 | o.debounceMu.Unlock() | |
| 851 | 828 | } | |
| 852 | 829 | ||
| 853 | - | // setGhostText displays a ghost completion suffix in the overlay label. | |
| 854 | - | // The ghost text shows what would complete the current input. | |
| 855 | - | // originalInput is the input that was used for the autocomplete query. | |
| 856 | - | // If originalInput is empty, the ghost text replaces the entire input (used for row selection). | |
| 830 | + | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |
| 831 | + | o.mu.RLock() | |
| 832 | + | hasGhost := o.hasGhostText | |
| 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 | 861 | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |
| 858 | - | if o.ghostLabel == nil || suffix == "" { | |
| 862 | + | if o.entry == nil || suffix == "" { | |
| 859 | 863 | o.clearGhostText() | |
| 860 | 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 | 874 | o.mu.Lock() | |
| 875 | + | o.realInput = originalInput | |
| 865 | 876 | o.ghostSuffix = suffix | |
| 866 | 877 | o.ghostFullText = fullText | |
| 867 | 878 | o.hasGhostText = true | |
| 879 | + | capturedToken := o.ghostToken | |
| 868 | 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 | 882 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 875 | - | if o.ghostLabel == nil || o.entry == nil { | |
| 876 | - | return false | |
| 877 | - | } | |
| 878 | - | if o.shouldSkipGhostUpdate(originalInput) { | |
| 883 | + | if o.entry == nil { | |
| 879 | 884 | return false | |
| 880 | 885 | } | |
| 881 | - | if !o.ghostPositionReady(attempt) { | |
| 882 | - | o.retryGhostPosition(originalInput, suffix, attempt+1) | |
| 886 | + | ||
| 887 | + | o.mu.RLock() | |
| 888 | + | currentInput := o.realInput | |
| 889 | + | currentToken := o.ghostToken | |
| 890 | + | o.mu.RUnlock() | |
| 891 | + | if currentInput != originalInput { | |
| 883 | 892 | return false | |
| 884 | 893 | } | |
| 885 | - | ||
| 886 | - | marginStart, ok := o.computeGhostMarginStart(originalInput) | |
| 887 | - | if !ok { | |
| 894 | + | if capturedToken != currentToken { | |
| 888 | 895 | return false | |
| 889 | 896 | } | |
| 890 | - | o.applyGhostPosition(suffix, marginStart) | |
| 891 | - | return false | |
| 892 | - | } | |
| 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 | - | } | |
| 897 | + | o.mu.Lock() | |
| 898 | + | o.isAcceptingGhost = true | |
| 899 | + | o.mu.Unlock() | |
| 925 | 900 | ||
| 926 | - | return false | |
| 927 | - | } | |
| 901 | + | o.entry.SetText(displayText) | |
| 902 | + | o.entry.SetPosition(selectionStart) | |
| 903 | + | o.entry.SelectRegion(selectionStart, selectionEnd) | |
| 928 | 904 | ||
| 929 | - | func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) { | |
| 930 | - | var retryCb glib.SourceFunc = func(uintptr) bool { | |
| 931 | - | o.scheduleGhostPosition(originalInput, suffix, attempt) | |
| 905 | + | o.mu.Lock() | |
| 906 | + | o.isAcceptingGhost = false | |
| 907 | + | o.mu.Unlock() | |
| 932 | 908 | return false | |
| 933 | 909 | } | |
| 934 | - | glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 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) | |
| 910 | + | glib.IdleAdd(&cb, 0) | |
| 992 | 911 | } | |
| 993 | 912 | ||
| 994 | 913 | // clearGhostText hides the ghost completion text. | |
| 995 | 914 | func (o *Omnibox) clearGhostText() { | |
| 996 | 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 | 921 | o.ghostSuffix = "" | |
| 998 | 922 | o.ghostFullText = "" | |
| 999 | 923 | o.hasGhostText = false | |
| 1000 | 924 | o.mu.Unlock() | |
| 1001 | 925 | ||
| 1002 | - | if o.ghostLabel == nil { | |
| 926 | + | if !hadGhost || o.entry == nil { | |
| 1003 | 927 | return | |
| 1004 | 928 | } | |
| 1005 | 929 | ||
| 1006 | 930 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 1007 | - | if o.ghostLabel != nil { | |
| 1008 | - | o.ghostLabel.SetVisible(false) | |
| 1009 | - | o.ghostLabel.SetText("") | |
| 931 | + | if o.entry == nil { | |
| 932 | + | return false | |
| 1010 | 933 | } | |
| 1011 | - | // Restore placeholder text | |
| 1012 | - | if o.entry != nil { | |
| 1013 | - | placeholder := defaultOmniboxPlaceholder | |
| 1014 | - | o.entry.SetPlaceholderText(&placeholder) | |
| 934 | + | o.mu.RLock() | |
| 935 | + | currentToken := o.ghostToken | |
| 936 | + | o.mu.RUnlock() | |
| 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 | 953 | return false | |
| 1017 | 954 | } | |
| 1018 | 955 | glib.IdleAdd(&cb, 0) | |
| @@ -1021,54 +958,42 @@ func (o *Omnibox) clearGhostText() { | |||
| 1021 | 958 | // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput. | |
| 1022 | 959 | // This prevents stale background results from wiping newer ghost text. | |
| 1023 | 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 | 965 | return | |
| 1026 | 966 | } | |
| 1027 | - | ||
| 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) | |
| 967 | + | o.clearGhostText() | |
| 1049 | 968 | } | |
| 1050 | 969 | ||
| 1051 | 970 | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| 1052 | 971 | func (o *Omnibox) acceptGhostCompletion() { | |
| 1053 | 972 | o.mu.Lock() | |
| 1054 | - | if !o.hasGhostText || o.ghostFullText == "" { | |
| 973 | + | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) | |
| 974 | + | if !accepted { | |
| 1055 | 975 | o.mu.Unlock() | |
| 1056 | 976 | return | |
| 1057 | 977 | } | |
| 1058 | - | fullText := o.ghostFullText | |
| 1059 | 978 | o.isAcceptingGhost = true | |
| 1060 | 979 | o.realInput = fullText | |
| 980 | + | o.selectedIndex = -1 | |
| 981 | + | o.hasNavigated = false | |
| 982 | + | o.ghostToken++ | |
| 1061 | 983 | o.ghostSuffix = "" | |
| 1062 | 984 | o.ghostFullText = "" | |
| 1063 | 985 | o.hasGhostText = false | |
| 1064 | 986 | o.mu.Unlock() | |
| 1065 | 987 | ||
| 1066 | - | // Update entry text and clear ghost label | |
| 1067 | - | o.entry.SetText(fullText) | |
| 1068 | - | o.entry.SetPosition(-1) // Move cursor to end | |
| 1069 | - | ||
| 1070 | - | if o.ghostLabel != nil { | |
| 1071 | - | o.ghostLabel.SetVisible(false) | |
| 988 | + | if o.entry != nil { | |
| 989 | + | if o.entry.GetText() != fullText { | |
| 990 | + | o.entry.SetText(fullText) | |
| 991 | + | } | |
| 992 | + | o.entry.SelectRegion(-1, -1) | |
| 993 | + | o.entry.SetPosition(-1) | |
| 994 | + | } | |
| 995 | + | if o.listBox != nil { | |
| 996 | + | o.listBox.SelectRow(nil) | |
| 1072 | 997 | } | |
| 1073 | 998 | ||
| 1074 | 999 | o.mu.Lock() | |
| @@ -1076,6 +1001,13 @@ func (o *Omnibox) acceptGhostCompletion() { | |||
| 1076 | 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 | 1011 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | |
| 1080 | 1012 | // When a row is selected via arrow keys, show the URL as ghost text even if | |
| 1081 | 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 | 1120 | go func() { | |
| 1183 | 1121 | suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{ | |
| 1184 | 1122 | VisibleURLs: visibleURLs, | |
| 1185 | 1123 | AllowBangs: false, | |
| 1186 | 1124 | }) | |
| 1125 | + | if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) { | |
| 1126 | + | return | |
| 1127 | + | } | |
| 1187 | 1128 | if suggestion == nil { | |
| 1188 | 1129 | o.clearGhostTextIfInput(entryText) | |
| 1189 | 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 | 1191 | // performSearch executes the search based on current view mode and query. | |
| 1196 | 1192 | func (o *Omnibox) performSearch() { | |
| 1197 | 1193 | o.mu.RLock() | |
| @@ -1215,6 +1211,11 @@ func (o *Omnibox) performSearch() { | |||
| 1215 | 1211 | o.lastQuery = query | |
| 1216 | 1212 | o.debounceMu.Unlock() | |
| 1217 | 1213 | ||
| 1214 | + | o.mu.Lock() | |
| 1215 | + | o.searchToken++ | |
| 1216 | + | token := o.searchToken | |
| 1217 | + | o.mu.Unlock() | |
| 1218 | + | ||
| 1218 | 1219 | if strings.HasPrefix(query, "!") { | |
| 1219 | 1220 | o.updateBangDetection(query) | |
| 1220 | 1221 | o.loadBangSuggestions(query) | |
| @@ -1224,23 +1225,23 @@ func (o *Omnibox) performSearch() { | |||
| 1224 | 1225 | o.clearBangState() | |
| 1225 | 1226 | ||
| 1226 | 1227 | if mode == ViewModeFavorites { | |
| 1227 | - | o.loadFavorites(query) | |
| 1228 | + | o.loadFavorites(query, token) | |
| 1228 | 1229 | return | |
| 1229 | 1230 | } | |
| 1230 | 1231 | ||
| 1231 | 1232 | // History search | |
| 1232 | 1233 | if query == "" { | |
| 1233 | - | o.loadInitialHistory() | |
| 1234 | + | o.loadInitialHistory(token) | |
| 1234 | 1235 | return | |
| 1235 | 1236 | } | |
| 1236 | 1237 | ||
| 1237 | 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 | 1242 | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1242 | 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 | 1245 | go func() { | |
| 1245 | 1246 | ctx := o.ctx | |
| 1246 | 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 | - | o.idleAddUpdateSuggestions(suggestions, query) | |
| 1295 | + | // Marshal back to GTK main thread, passing query for stale-result guarding. | |
| 1296 | + | o.idleAddUpdateSuggestions(suggestions, query, token) | |
| 1296 | 1297 | }() | |
| 1297 | 1298 | } | |
| 1298 | 1299 | ||
| 1299 | 1300 | // loadInitialHistory loads history based on InitialBehavior config. | |
| 1300 | - | func (o *Omnibox) loadInitialHistory() { | |
| 1301 | + | func (o *Omnibox) loadInitialHistory(token uint64) { | |
| 1301 | 1302 | if o.historyUC == nil { | |
| 1302 | 1303 | return | |
| 1303 | 1304 | } | |
| @@ -1312,7 +1313,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1312 | 1313 | ||
| 1313 | 1314 | switch o.initialBehavior { | |
| 1314 | 1315 | case "none": | |
| 1315 | - | o.idleAddUpdateSuggestions(nil, "") | |
| 1316 | + | o.idleAddUpdateSuggestions(nil, "", token) | |
| 1316 | 1317 | return | |
| 1317 | 1318 | ||
| 1318 | 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 | 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 | 1367 | if o.favoritesUC == nil { | |
| 1367 | 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 | 1977 | log.Debug().Msg("remove favorite: invalid ID") | |
| 1977 | 1978 | return | |
| 1978 | 1979 | } | |
| 1980 | + | entryText := o.entry.GetText() | |
| 1979 | 1981 | ||
| 1980 | 1982 | go func() { | |
| 1981 | 1983 | ctx := o.ctx | |
| @@ -2000,7 +2002,10 @@ func (o *Omnibox) toggleFavorite() { | |||
| 2000 | 2002 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | |
| 2001 | 2003 | ||
| 2002 | 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 | 2009 | if o.onToast != nil { | |
| 2005 | 2010 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 2006 | 2011 | o.onToast(ctx, "Favorite removed", ToastSuccess) | |
| @@ -2139,13 +2144,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2139 | 2144 | o.isAcceptingGhost = false | |
| 2140 | 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 | 2147 | // Set initial query | |
| 2148 | 2148 | o.entry.SetText(query) | |
| 2149 | + | o.entry.SelectRegion(-1, -1) | |
| 2149 | 2150 | ||
| 2150 | 2151 | // Determine if we expect content initially | |
| 2151 | 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 | 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) { | |
| 2272 | + | func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) { | |
| 2273 | 2273 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 2274 | + | if !o.isSearchTokenCurrent(token) { | |
| 2275 | + | return false | |
| 2276 | + | } | |
| 2274 | 2277 | o.updateSuggestions(suggestions, query) | |
| 2275 | 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 | 2298 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | |
| 2296 | - | // The query parameter is passed through to ensure ghost text uses the correct input. | |
| 2297 | - | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) { | |
| 2299 | + | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) { | |
| 2298 | 2300 | var cb glib.SourceFunc = func(data uintptr) bool { | |
| 2301 | + | if !o.isSearchTokenCurrent(token) { | |
| 2302 | + | return false | |
| 2303 | + | } | |
| 2299 | 2304 | o.updateFavorites(favorites, query) | |
| 2300 | 2305 | return false // One-shot callback, return false to remove source | |
| 2301 | 2306 | } | |
| 2302 | 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 | 2316 | // Widget returns the omnibox widget for embedding in an overlay. | |
| 2306 | 2317 | func (o *Omnibox) Widget() *gtk.Widget { | |
| 2307 | 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 | 29 | paneID entity.PaneID | |
| 30 | 30 | isActive bool | |
| 31 | 31 | ||
| 32 | - | onFocusIn func(paneID entity.PaneID) | |
| 33 | - | onFocusOut func(paneID entity.PaneID) | |
| 34 | - | onHover func(paneID entity.PaneID) | |
| 32 | + | onFocusIn func(paneID entity.PaneID) | |
| 33 | + | onFocusOut func(paneID entity.PaneID) | |
| 34 | + | onHover func(paneID entity.PaneID) | |
| 35 | + | onMouseMotion func() | |
| 35 | 36 | ||
| 36 | 37 | hoverHandler *input.HoverHandler | |
| 37 | 38 | ||
| @@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) { | |||
| 218 | 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 | 231 | // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior. | |
| 222 | 232 | func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |
| 223 | 233 | pv.mu.Lock() | |
| @@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) { | |||
| 226 | 236 | // Create hover handler | |
| 227 | 237 | pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID) | |
| 228 | 238 | ||
| 229 | - | // Wire up callback | |
| 239 | + | // Wire up hover enter callback | |
| 230 | 240 | pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) { | |
| 231 | 241 | pv.mu.RLock() | |
| 232 | 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 | 261 | // Attach to overlay widget | |
| 241 | 262 | gtkWidget := pv.overlay.GtkWidget() | |
| 242 | 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 | 15 | mockContent := mocks.NewMockBoxWidget(t) | |
| 16 | 16 | mockSpinner := mocks.NewMockSpinnerWidget(t) | |
| 17 | 17 | mockLogo := mocks.NewMockImageWidget(t) | |
| 18 | + | mockVersion := mocks.NewMockLabelWidget(t) | |
| 18 | 19 | ||
| 19 | 20 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once() | |
| 20 | 21 | mockContainer.EXPECT().SetHexpand(true).Once() | |
| @@ -32,26 +33,36 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) { | |||
| 32 | 33 | mockContent.EXPECT().SetCanTarget(false).Once() | |
| 33 | 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 | 36 | mockFactory.EXPECT().NewImage().Return(mockLogo).Once() | |
| 44 | 37 | mockLogo.EXPECT().SetHalign(mock.Anything).Once() | |
| 45 | 38 | mockLogo.EXPECT().SetValign(mock.Anything).Once() | |
| 46 | 39 | mockLogo.EXPECT().SetCanFocus(false).Once() | |
| 47 | 40 | mockLogo.EXPECT().SetCanTarget(false).Once() | |
| 48 | - | mockLogo.EXPECT().SetSizeRequest(512, 512).Once() | |
| 49 | - | mockLogo.EXPECT().SetPixelSize(512).Once() | |
| 41 | + | mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once() | |
| 42 | + | mockLogo.EXPECT().SetPixelSize(mock.Anything).Once() | |
| 50 | 43 | mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once() | |
| 51 | 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 | 63 | mockContent.EXPECT().Append(mockLogo).Once() | |
| 54 | 64 | mockContent.EXPECT().Append(mockSpinner).Once() | |
| 65 | + | mockContent.EXPECT().Append(mockVersion).Once() | |
| 55 | 66 | mockContainer.EXPECT().Append(mockContent).Once() | |
| 56 | 67 | ||
| 57 | 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 | 34 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 35 | 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 | 39 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 40 | 40 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| @@ -75,7 +75,7 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||
| 75 | 75 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 76 | 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 | 80 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 81 | 81 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| @@ -509,6 +509,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) { | |||
| 509 | 509 | ||
| 510 | 510 | // Helper function to setup common mock expectations for PaneView creation | |
| 511 | 511 | func setupLoadingSkeletonMocks( | |
| 512 | + | t *testing.T, | |
| 512 | 513 | mockFactory *mocks.MockWidgetFactory, | |
| 513 | 514 | mockOverlay *mocks.MockOverlayWidget, | |
| 514 | 515 | mockLoadingContainer *mocks.MockBoxWidget, | |
| @@ -516,6 +517,8 @@ func setupLoadingSkeletonMocks( | |||
| 516 | 517 | mockLoadingSpinner *mocks.MockSpinnerWidget, | |
| 517 | 518 | mockLoadingLogo *mocks.MockImageWidget, | |
| 518 | 519 | ) { | |
| 520 | + | mockLoadingVersion := mocks.NewMockLabelWidget(t) | |
| 521 | + | ||
| 519 | 522 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once() | |
| 520 | 523 | mockLoadingContainer.EXPECT().SetHexpand(true).Maybe() | |
| 521 | 524 | mockLoadingContainer.EXPECT().SetVexpand(true).Maybe() | |
| @@ -533,6 +536,16 @@ func setupLoadingSkeletonMocks( | |||
| 533 | 536 | mockLoadingContent.EXPECT().SetCanTarget(false).Maybe() | |
| 534 | 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 | 549 | mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once() | |
| 537 | 550 | mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 538 | 551 | mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe() | |
| @@ -542,18 +555,18 @@ func setupLoadingSkeletonMocks( | |||
| 542 | 555 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | |
| 543 | 556 | mockLoadingSpinner.EXPECT().Start().Maybe() | |
| 544 | 557 | ||
| 545 | - | mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once() | |
| 546 | - | mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 547 | - | mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe() | |
| 548 | - | mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe() | |
| 549 | - | mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe() | |
| 550 | - | mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe() | |
| 551 | - | mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe() | |
| 552 | - | mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe() | |
| 553 | - | mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |
| 558 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once() | |
| 559 | + | mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 560 | + | mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe() | |
| 561 | + | mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe() | |
| 562 | + | mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe() | |
| 563 | + | mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe() | |
| 564 | + | mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe() | |
| 565 | + | mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe() | |
| 554 | 566 | ||
| 555 | 567 | mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe() | |
| 556 | 568 | mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe() | |
| 569 | + | mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe() | |
| 557 | 570 | mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe() | |
| 558 | 571 | ||
| 559 | 572 | mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once() | |
| @@ -581,7 +594,7 @@ func setupPaneViewMocks( | |||
| 581 | 594 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 582 | 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 | 599 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 587 | 600 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| @@ -611,7 +624,7 @@ func setupPaneViewMocksNoWebView( | |||
| 611 | 624 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 612 | 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 | 629 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 617 | 630 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
M internal/ui/component/workspace_view.go +26 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "sync" | |
| 9 | + | "sync/atomic" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -56,6 +57,14 @@ type WorkspaceView struct { | |||
| 56 | 57 | // Prevents hover focus from overriding keyboard-initiated focus changes | |
| 57 | 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 | 68 | // Auto-open omnibox on new pane creation | |
| 60 | 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 | 112 | // Set up hover callback for focus-follows-mouse | |
| 97 | 113 | pv.SetOnHover(func(paneID entity.PaneID) { | |
| 98 | 114 | // Skip if hover is suppressed (keyboard navigation just happened) | |
| @@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi | |||
| 100 | 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 | 125 | // Skip if this pane is already active | |
| 104 | 126 | if a.wv.GetActivePaneID() == paneID { | |
| 105 | 127 | return | |
| @@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) { | |||
| 566 | 588 | // NotifyNewPaneCreated handles new pane creation events. | |
| 567 | 589 | // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox. | |
| 568 | 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 | 595 | wv.SuppressHover(KeyboardFocusSuppressDuration) | |
| 571 | 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 | 17 | func setupLoadingSkeletonMocksWorkspace( | |
| 18 | + | t *testing.T, | |
| 18 | 19 | mockFactory *mocks.MockWidgetFactory, | |
| 19 | 20 | mockOverlay *mocks.MockOverlayWidget, | |
| 20 | 21 | mockLoadingContainer *mocks.MockBoxWidget, | |
| @@ -22,6 +23,8 @@ func setupLoadingSkeletonMocksWorkspace( | |||
| 22 | 23 | mockLoadingSpinner *mocks.MockSpinnerWidget, | |
| 23 | 24 | mockLoadingLogo *mocks.MockImageWidget, | |
| 24 | 25 | ) { | |
| 26 | + | mockLoadingVersion := mocks.NewMockLabelWidget(t) | |
| 27 | + | ||
| 25 | 28 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once() | |
| 26 | 29 | mockLoadingContainer.EXPECT().SetHexpand(true).Maybe() | |
| 27 | 30 | mockLoadingContainer.EXPECT().SetVexpand(true).Maybe() | |
| @@ -39,27 +42,37 @@ func setupLoadingSkeletonMocksWorkspace( | |||
| 39 | 42 | mockLoadingContent.EXPECT().SetCanTarget(false).Maybe() | |
| 40 | 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 | 55 | mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once() | |
| 43 | 56 | mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 44 | 57 | mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe() | |
| 45 | 58 | mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe() | |
| 46 | 59 | mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe() | |
| 47 | - | mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe() | |
| 60 | + | mockLoadingSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe() | |
| 48 | 61 | mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe() | |
| 49 | 62 | mockLoadingSpinner.EXPECT().Start().Maybe() | |
| 50 | 63 | ||
| 51 | - | mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once() | |
| 52 | - | mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 53 | - | mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe() | |
| 54 | - | mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe() | |
| 55 | - | mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe() | |
| 56 | - | mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe() | |
| 57 | - | mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe() | |
| 58 | - | mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe() | |
| 59 | - | mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe() | |
| 64 | + | mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once() | |
| 65 | + | mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe() | |
| 66 | + | mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe() | |
| 67 | + | mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe() | |
| 68 | + | mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe() | |
| 69 | + | mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe() | |
| 70 | + | mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe() | |
| 71 | + | mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe() | |
| 60 | 72 | ||
| 61 | 73 | mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe() | |
| 62 | 74 | mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe() | |
| 75 | + | mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe() | |
| 63 | 76 | mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe() | |
| 64 | 77 | ||
| 65 | 78 | mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once() | |
| @@ -101,7 +114,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||
| 101 | 114 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 102 | 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 | 119 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 107 | 120 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| @@ -166,7 +179,7 @@ func setupStackedLeafMocks( | |||
| 166 | 179 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |
| 167 | 180 | ||
| 168 | 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 | 184 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |
| 172 | 185 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |
| @@ -274,9 +287,9 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||
| 274 | 287 | mockStackBox2.EXPECT().SetVisible(true).Once() | |
| 275 | 288 | mockPaned.EXPECT().SetStartChild(mockStackBox1).Once() | |
| 276 | 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 | 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 | 293 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 281 | 294 | ||
| 282 | 295 | mockBox.EXPECT().Append(mockPaned).Once() | |
M internal/ui/coordinator/content.go +353 -103
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | + | "net/url" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "time" | |
| @@ -20,12 +21,16 @@ import ( | |||
| 20 | 21 | "github.com/bnema/dumber/internal/ui/component" | |
| 21 | 22 | "github.com/bnema/dumber/internal/ui/input" | |
| 22 | 23 | "github.com/bnema/dumber/internal/ui/layout" | |
| 24 | + | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |
| 23 | 25 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 26 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 24 | 27 | ) | |
| 25 | 28 | ||
| 26 | 29 | const ( | |
| 27 | - | aboutBlankURI = "about:blank" | |
| 28 | - | logURLMaxLen = 80 | |
| 30 | + | aboutBlankURI = "about:blank" | |
| 31 | + | crashPageURI = "dumb://home/crash" | |
| 32 | + | logURLMaxLen = 80 | |
| 33 | + | oauthParentRefreshDebounce = 200 * time.Millisecond | |
| 29 | 34 | ||
| 30 | 35 | // Dark theme background color (#0a0a0b) as float32 RGBA values | |
| 31 | 36 | darkBgR = 0.039 | |
| @@ -34,6 +39,51 @@ const ( | |||
| 34 | 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 | 87 | // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. | |
| 38 | 88 | type ContentCoordinator struct { | |
| 39 | 89 | pool *webkit.WebViewPool | |
| @@ -43,6 +93,7 @@ type ContentCoordinator struct { | |||
| 43 | 93 | injector *webkit.ContentInjector | |
| 44 | 94 | ||
| 45 | 95 | webViews map[entity.PaneID]*webkit.WebView | |
| 96 | + | webViewsMu sync.RWMutex | |
| 46 | 97 | paneTitles map[entity.PaneID]string | |
| 47 | 98 | titleMu sync.RWMutex | |
| 48 | 99 | ||
| @@ -87,6 +138,8 @@ type ContentCoordinator struct { | |||
| 87 | 138 | factory *webkit.WebViewFactory | |
| 88 | 139 | popupConfig *config.PopupBehaviorConfig | |
| 89 | 140 | pendingPopups map[port.WebViewID]*PendingPopup | |
| 141 | + | popupOAuth map[port.WebViewID]*popupOAuthState | |
| 142 | + | popupRefresh map[entity.PaneID]*time.Timer | |
| 90 | 143 | popupMu sync.RWMutex | |
| 91 | 144 | ||
| 92 | 145 | // Callback to insert popup into workspace (avoids circular dependency) | |
| @@ -117,6 +170,14 @@ type pendingThemeUpdate struct { | |||
| 117 | 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 | 181 | // NewContentCoordinator creates a new ContentCoordinator. | |
| 121 | 182 | func NewContentCoordinator( | |
| 122 | 183 | ctx context.Context, | |
| @@ -140,6 +201,8 @@ func NewContentCoordinator( | |||
| 140 | 201 | pendingReveal: make(map[entity.PaneID]bool), | |
| 141 | 202 | getActiveWS: getActiveWS, | |
| 142 | 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 | 262 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 200 | 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 | 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 | 273 | // Mark tab_created on first webview (first tab) | |
| 211 | - | if len(c.webViews) == 0 { | |
| 274 | + | if c.webViewCount() == 0 { | |
| 212 | 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 | 282 | logging.Trace().Mark("webview_acquired") | |
| 220 | 283 | ||
| 221 | - | c.webViews[paneID] = wv | |
| 284 | + | c.setWebViewLocked(paneID, wv) | |
| 222 | 285 | c.setupWebViewCallbacks(ctx, paneID, wv) | |
| 223 | 286 | ||
| 224 | 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 | 292 | func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { | |
| 230 | 293 | log := logging.FromContext(ctx) | |
| 231 | 294 | ||
| 232 | - | wv, ok := c.webViews[paneID] | |
| 233 | - | if !ok || wv == nil { | |
| 295 | + | wv := c.deleteWebViewLocked(paneID) | |
| 296 | + | if wv == nil { | |
| 234 | 297 | return | |
| 235 | 298 | } | |
| 236 | - | delete(c.webViews, paneID) | |
| 237 | 299 | c.clearPendingAppearance(paneID) | |
| 238 | 300 | ||
| 239 | 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 | 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 | 428 | // GetWebView returns the WebView for a specific pane. | |
| 367 | 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 | 433 | // RegisterPopupWebView registers a popup WebView that was created externally. | |
| 372 | 434 | // This is used when popup tabs are created and the WebView needs to be tracked. | |
| 373 | 435 | func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) { | |
| 374 | 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 | 477 | // ApplyFiltersToAll applies content filters to all active webviews. | |
| 380 | 478 | // Called when filters become available after webviews were already created. | |
| 381 | 479 | func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) { | |
| 382 | 480 | log := logging.FromContext(ctx) | |
| 383 | 481 | ||
| 384 | - | for paneID, wv := range c.webViews { | |
| 482 | + | for paneID, wv := range c.snapshotWebViews() { | |
| 385 | 483 | if wv != nil && !wv.IsDestroyed() { | |
| 386 | 484 | applier.ApplyTo(ctx, wv.UserContentManager()) | |
| 387 | 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 | 494 | return | |
| 397 | 495 | } | |
| 398 | 496 | ||
| 399 | - | for paneID, wv := range c.webViews { | |
| 497 | + | for paneID, wv := range c.snapshotWebViews() { | |
| 400 | 498 | if wv == nil || wv.IsDestroyed() { | |
| 401 | 499 | continue | |
| 402 | 500 | } | |
| @@ -740,10 +838,10 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||
| 740 | 838 | ||
| 741 | 839 | // Notify history persistence (get URL from WebView) | |
| 742 | 840 | if c.onTitleUpdated != nil { | |
| 743 | - | if wv := c.webViews[paneID]; wv != nil { | |
| 744 | - | url := wv.URI() | |
| 745 | - | if url != "" && title != "" { | |
| 746 | - | c.onTitleUpdated(ctx, paneID, url, title) | |
| 841 | + | if wv := c.getWebViewLocked(paneID); wv != nil { | |
| 842 | + | currentURI := wv.URI() | |
| 843 | + | if currentURI != "" && 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 | 899 | // onFaviconChanged updates favicon tracking when a WebView's favicon changes. | |
| 786 | 900 | func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) { | |
| 787 | 901 | log := logging.FromContext(ctx) | |
| 788 | 902 | ||
| 789 | 903 | // Get current URI to extract domain for caching | |
| 790 | - | wv := c.webViews[paneID] | |
| 904 | + | wv := c.getWebViewLocked(paneID) | |
| 791 | 905 | if wv == nil { | |
| 792 | 906 | return | |
| 793 | 907 | } | |
| @@ -851,27 +965,25 @@ func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter { | |||
| 851 | 965 | // SetNavigationOrigin records the original URL before navigation starts. | |
| 852 | 966 | // This allows caching favicons under both original and final domains | |
| 853 | 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 | 969 | c.navOriginMu.Lock() | |
| 856 | - | c.navOrigins[paneID] = url | |
| 970 | + | c.navOrigins[paneID] = uri | |
| 857 | 971 | c.navOriginMu.Unlock() | |
| 858 | 972 | } | |
| 859 | 973 | ||
| 860 | 974 | // PreloadCachedFavicon checks the favicon cache and updates the stacked pane | |
| 861 | 975 | // title bar immediately if a cached favicon exists for the URL. | |
| 862 | 976 | // This provides instant favicon display without waiting for WebKit. | |
| 863 | - | func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) { | |
| 864 | - | if c.faviconAdapter == nil || url == "" { | |
| 977 | + | func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) { | |
| 978 | + | if c.faviconAdapter == nil || uri == "" { | |
| 865 | 979 | return | |
| 866 | 980 | } | |
| 867 | 981 | ||
| 868 | 982 | // Check memory and disk cache (no external fetch) | |
| 869 | - | texture := c.faviconAdapter.PreloadFromCache(url) | |
| 870 | - | if texture == nil { | |
| 871 | - | return | |
| 872 | - | } | |
| 983 | + | texture := c.faviconAdapter.PreloadFromCache(uri) | |
| 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 | 987 | _, wsView := c.getActiveWS() | |
| 876 | 988 | if wsView != nil { | |
| 877 | 989 | tr := wsView.TreeRenderer() | |
| @@ -892,14 +1004,14 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 892 | 1004 | log := logging.FromContext(ctx) | |
| 893 | 1005 | logging.Trace().Mark("load_committed") | |
| 894 | 1006 | ||
| 895 | - | url := wv.URI() | |
| 896 | - | if url == "" { | |
| 1007 | + | uri := wv.URI() | |
| 1008 | + | if uri == "" { | |
| 897 | 1009 | return | |
| 898 | 1010 | } | |
| 899 | 1011 | ||
| 900 | 1012 | // Set appropriate background color based on page type to prevent dark background bleeding. | |
| 901 | 1013 | switch { | |
| 902 | - | case strings.HasPrefix(url, "dumb://"): | |
| 1014 | + | case strings.HasPrefix(uri, "dumb://"): | |
| 903 | 1015 | // Internal pages: apply themed background | |
| 904 | 1016 | theme, ok := c.getCurrentTheme() | |
| 905 | 1017 | if ok && theme.prefersDark { | |
| @@ -907,7 +1019,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 907 | 1019 | } else { | |
| 908 | 1020 | wv.ResetBackgroundToDefault() | |
| 909 | 1021 | } | |
| 910 | - | case strings.HasPrefix(url, "about:"): | |
| 1022 | + | case strings.HasPrefix(uri, "about:"): | |
| 911 | 1023 | // Keep pool background (no action) | |
| 912 | 1024 | default: | |
| 913 | 1025 | // External pages: white background | |
| @@ -919,7 +1031,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 919 | 1031 | // Skip showing if this is about:blank but the pane is loading a different URL | |
| 920 | 1032 | // This prevents the brief flash of about:blank during initial navigation | |
| 921 | 1033 | shouldShow := true | |
| 922 | - | if url == aboutBlankURI { | |
| 1034 | + | if uri == aboutBlankURI { | |
| 923 | 1035 | // Get the pane's intended URI from the workspace | |
| 924 | 1036 | ws, _ := c.getActiveWS() | |
| 925 | 1037 | if ws != nil { | |
| @@ -950,15 +1062,22 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 950 | 1062 | ||
| 951 | 1063 | c.markPendingReveal(paneID) | |
| 952 | 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 | 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 | 1078 | // Record history - URI is guaranteed to be correct at LoadCommitted | |
| 960 | 1079 | if c.onHistoryRecord != nil { | |
| 961 | - | c.onHistoryRecord(ctx, paneID, url) | |
| 1080 | + | c.onHistoryRecord(ctx, paneID, uri) | |
| 962 | 1081 | } | |
| 963 | 1082 | ||
| 964 | 1083 | // Apply zoom | |
| @@ -966,7 +1085,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 966 | 1085 | return | |
| 967 | 1086 | } | |
| 968 | 1087 | ||
| 969 | - | domain, err := usecase.ExtractDomain(url) | |
| 1088 | + | domain, err := usecase.ExtractDomain(uri) | |
| 970 | 1089 | if err != nil { | |
| 971 | 1090 | return | |
| 972 | 1091 | } | |
| @@ -997,24 +1116,21 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID | |||
| 997 | 1116 | ||
| 998 | 1117 | // onSPANavigation records history when URL changes via JavaScript (History API). | |
| 999 | 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) { | |
| 1001 | - | log := logging.FromContext(ctx) | |
| 1002 | - | log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected") | |
| 1003 | - | ||
| 1119 | + | func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) { | |
| 1004 | 1120 | // Update domain model with current URI for session snapshots | |
| 1005 | - | c.updatePaneURI(paneID, url) | |
| 1121 | + | c.updatePaneURI(paneID, uri) | |
| 1006 | 1122 | ||
| 1007 | 1123 | // Record history for SPA navigation | |
| 1008 | 1124 | if c.onHistoryRecord != nil { | |
| 1009 | - | c.onHistoryRecord(ctx, paneID, url) | |
| 1125 | + | c.onHistoryRecord(ctx, paneID, uri) | |
| 1010 | 1126 | } | |
| 1011 | 1127 | } | |
| 1012 | 1128 | ||
| 1013 | 1129 | // updatePaneURI updates the pane's URI in the domain model. | |
| 1014 | 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 | 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 | 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 | 1210 | c.revealMu.Lock() | |
| 1095 | 1211 | pending := c.pendingReveal[paneID] | |
| 1096 | 1212 | if pending { | |
| @@ -1102,7 +1218,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity. | |||
| 1102 | 1218 | return | |
| 1103 | 1219 | } | |
| 1104 | 1220 | ||
| 1105 | - | wv := c.webViews[paneID] | |
| 1221 | + | wv := c.getWebViewLocked(paneID) | |
| 1106 | 1222 | if wv == nil || wv.IsDestroyed() { | |
| 1107 | 1223 | return | |
| 1108 | 1224 | } | |
| @@ -1112,7 +1228,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity. | |||
| 1112 | 1228 | logging.FromContext(ctx). | |
| 1113 | 1229 | Debug(). | |
| 1114 | 1230 | Str("pane_id", string(paneID)). | |
| 1115 | - | Str("url", url). | |
| 1231 | + | Str("uri", uri). | |
| 1116 | 1232 | Str("reason", reason). | |
| 1117 | 1233 | Msg("webview revealed") | |
| 1118 | 1234 | } | |
| @@ -1294,7 +1410,7 @@ func (c *ContentCoordinator) handlePopupCreate( | |||
| 1294 | 1410 | } | |
| 1295 | 1411 | ||
| 1296 | 1412 | // Register WebView in our map (after successful insertion) | |
| 1297 | - | c.webViews[paneID] = popupWV | |
| 1413 | + | c.setWebViewLocked(paneID, popupWV) | |
| 1298 | 1414 | ||
| 1299 | 1415 | // Setup standard callbacks (after successful insertion to avoid leak) | |
| 1300 | 1416 | c.setupWebViewCallbacks(ctx, paneID, popupWV) | |
| @@ -1302,7 +1418,8 @@ func (c *ContentCoordinator) handlePopupCreate( | |||
| 1302 | 1418 | // Setup OAuth auto-close if configured | |
| 1303 | 1419 | if hasConfig && oauthEnabled && isOAuth { | |
| 1304 | 1420 | popupPane.AutoClose = true | |
| 1305 | - | c.setupOAuthAutoClose(ctx, paneID, popupWV) | |
| 1421 | + | c.trackOAuthPopup(popupID, parentPaneID) | |
| 1422 | + | c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV) | |
| 1306 | 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 | 1443 | popupWV.OnReadyToShow = func() { | |
| 1327 | 1444 | c.handlePopupReadyToShow(ctx, popupID) | |
| 1328 | 1445 | } | |
| 1329 | - | popupWV.OnClose = func() { | |
| 1446 | + | popupWV.OnClose = composeOnClose(popupWV.OnClose, func() { | |
| 1330 | 1447 | c.handlePopupClose(ctx, popupID) | |
| 1331 | - | } | |
| 1448 | + | }) | |
| 1332 | 1449 | ||
| 1333 | 1450 | log.Info(). | |
| 1334 | 1451 | Uint64("popup_id", uint64(popupID)). | |
| @@ -1389,6 +1506,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||
| 1389 | 1506 | c.popupMu.Unlock() | |
| 1390 | 1507 | ||
| 1391 | 1508 | if wasPending && pending != nil { | |
| 1509 | + | c.handlePopupOAuthClose(ctx, popupID) | |
| 1392 | 1510 | // Was never shown, just destroy | |
| 1393 | 1511 | pending.WebView.Destroy() | |
| 1394 | 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 | 1525 | if paneID == "" { | |
| 1526 | + | c.handlePopupOAuthClose(ctx, popupID) | |
| 1408 | 1527 | log.Warn().Msg("popup close: could not find pane for webview") | |
| 1409 | 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 | 1540 | // Release the WebView (this will clean up tracking) | |
| 1420 | 1541 | c.ReleaseWebView(ctx, paneID) | |
| 1421 | 1542 | ||
| @@ -1424,6 +1545,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||
| 1424 | 1545 | ||
| 1425 | 1546 | // setupWebViewCallbacks configures standard callbacks and popup handling. | |
| 1426 | 1547 | func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 1548 | + | log := logging.FromContext(ctx) | |
| 1549 | + | ||
| 1427 | 1550 | // Title changes | |
| 1428 | 1551 | wv.OnTitleChanged = func(title string) { | |
| 1429 | 1552 | c.onTitleChanged(ctx, paneID, title) | |
| @@ -1453,7 +1576,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 1453 | 1576 | ||
| 1454 | 1577 | // SPA navigation and external scheme handling | |
| 1455 | 1578 | wv.OnURIChanged = func(uri string) { | |
| 1456 | - | log := logging.FromContext(ctx) | |
| 1457 | 1579 | if uri == "" { | |
| 1458 | 1580 | return | |
| 1459 | 1581 | } | |
| @@ -1461,12 +1583,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 1461 | 1583 | // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.) | |
| 1462 | 1584 | // These are typically triggered by JavaScript redirects (window.location) | |
| 1463 | 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 | 1587 | if isExternal { | |
| 1472 | 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 | 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 | 1649 | // Fullscreen handlers for idle inhibition | |
| 1504 | 1650 | c.setupIdleInhibitionHandlers(ctx, paneID, wv) | |
| 1505 | 1651 | ||
| @@ -1512,12 +1658,19 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 1512 | 1658 | // For providers using postMessage (like Google Sign-In), we rely on the provider calling | |
| 1513 | 1659 | // window.close() which triggers WebKit's close signal. | |
| 1514 | 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 | 1667 | log := logging.FromContext(ctx) | |
| 1517 | 1668 | ||
| 1518 | 1669 | // Safety timeout - only triggers if popup gets stuck (provider should close via window.close) | |
| 1519 | 1670 | var safetyTimer *time.Timer | |
| 1520 | 1671 | var safetyTimerMu sync.Mutex | |
| 1672 | + | var cancelSafetyTimerOnce sync.Once | |
| 1673 | + | var requestCloseOnce sync.Once | |
| 1521 | 1674 | const oauthSafetyTimeout = 30 * time.Second | |
| 1522 | 1675 | ||
| 1523 | 1676 | startSafetyTimer := func() { | |
| @@ -1535,69 +1688,166 @@ func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID ent | |||
| 1535 | 1688 | } | |
| 1536 | 1689 | ||
| 1537 | 1690 | cancelSafetyTimer := func() { | |
| 1538 | - | safetyTimerMu.Lock() | |
| 1539 | - | defer safetyTimerMu.Unlock() | |
| 1540 | - | if safetyTimer != nil { | |
| 1541 | - | safetyTimer.Stop() | |
| 1542 | - | safetyTimer = nil | |
| 1543 | - | } | |
| 1691 | + | cancelSafetyTimerOnce.Do(func() { | |
| 1692 | + | safetyTimerMu.Lock() | |
| 1693 | + | defer safetyTimerMu.Unlock() | |
| 1694 | + | if safetyTimer != nil { | |
| 1695 | + | safetyTimer.Stop() | |
| 1696 | + | safetyTimer = nil | |
| 1697 | + | } | |
| 1698 | + | }) | |
| 1544 | 1699 | } | |
| 1545 | 1700 | ||
| 1546 | - | // Start safety timer immediately | |
| 1547 | - | startSafetyTimer() | |
| 1548 | - | ||
| 1549 | - | // Wrap the existing OnURIChanged to also check for OAuth callbacks | |
| 1550 | - | originalURIChanged := wv.OnURIChanged | |
| 1551 | - | wv.OnURIChanged = func(uri string) { | |
| 1552 | - | // Call original handler first | |
| 1553 | - | if originalURIChanged != nil { | |
| 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") | |
| 1701 | + | requestOAuthClose := func(uri string, reason string) { | |
| 1702 | + | c.capturePopupOAuthState(popupID, uri) | |
| 1703 | + | cancelSafetyTimer() | |
| 1704 | + | log.Info(). | |
| 1705 | + | Str("pane", string(paneID)). | |
| 1706 | + | Str("reason", reason). | |
| 1707 | + | Msg("oauth callback detected, closing") | |
| 1708 | + | requestCloseOnce.Do(func() { | |
| 1561 | 1709 | go func() { | |
| 1562 | 1710 | time.Sleep(500 * time.Millisecond) | |
| 1563 | 1711 | if wv != nil && !wv.IsDestroyed() { | |
| 1564 | 1712 | wv.Close() | |
| 1565 | 1713 | } | |
| 1566 | 1714 | }() | |
| 1567 | - | } | |
| 1715 | + | }) | |
| 1568 | 1716 | } | |
| 1569 | 1717 | ||
| 1570 | - | // Monitor load events for URL-based detection | |
| 1571 | - | originalLoadChanged := wv.OnLoadChanged | |
| 1572 | - | wv.OnLoadChanged = func(event webkit.LoadEvent) { | |
| 1573 | - | if originalLoadChanged != nil { | |
| 1574 | - | originalLoadChanged(event) | |
| 1718 | + | // Start safety timer immediately. | |
| 1719 | + | startSafetyTimer() | |
| 1720 | + | ||
| 1721 | + | // Wrap OnURIChanged to check for OAuth callbacks. | |
| 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 | 1730 | if event == webkit.LoadCommitted { | |
| 1579 | 1731 | uri := wv.URI() | |
| 1580 | 1732 | if ShouldAutoClose(uri) { | |
| 1581 | - | cancelSafetyTimer() | |
| 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 | - | }() | |
| 1733 | + | requestOAuthClose(uri, "load_committed") | |
| 1589 | 1734 | } | |
| 1590 | 1735 | } | |
| 1591 | - | } | |
| 1736 | + | }) | |
| 1592 | 1737 | ||
| 1593 | - | // Wrap OnClose to cancel safety timer when popup closes normally | |
| 1594 | - | originalOnClose := wv.OnClose | |
| 1595 | - | wv.OnClose = func() { | |
| 1738 | + | // Cancel safety timer on any close path. | |
| 1739 | + | wv.OnClose = composeOnClose(func() { | |
| 1596 | 1740 | cancelSafetyTimer() | |
| 1597 | - | if originalOnClose != nil { | |
| 1598 | - | originalOnClose() | |
| 1599 | - | } | |
| 1741 | + | }, wv.OnClose) | |
| 1742 | + | } | |
| 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 | 1853 | // handleLinkMiddleClick handles middle-click / Ctrl+click on links. | |
| @@ -1643,7 +1893,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||
| 1643 | 1893 | newPane.URI = uri | |
| 1644 | 1894 | ||
| 1645 | 1895 | // Register WebView | |
| 1646 | - | c.webViews[paneID] = newWV | |
| 1896 | + | c.setWebViewLocked(paneID, newWV) | |
| 1647 | 1897 | ||
| 1648 | 1898 | // Setup callbacks | |
| 1649 | 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 | 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 | 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 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| 7 | 9 | // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress. | |
| @@ -138,3 +140,45 @@ func IsOAuthError(url string) bool { | |||
| 138 | 140 | lower := strings.ToLower(url) | |
| 139 | 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 | 35 | onCloseLastPane func(ctx context.Context) error | |
| 36 | 36 | onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior | |
| 37 | 37 | onStateChanged func() // For session snapshots | |
| 38 | + | onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | 41 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | |
| @@ -64,6 +65,16 @@ type stackPaneContext struct { | |||
| 64 | 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 | 78 | // NewWorkspaceCoordinator creates a new WorkspaceCoordinator. | |
| 68 | 79 | func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator { | |
| 69 | 80 | log := logging.FromContext(ctx) | |
| @@ -90,6 +101,11 @@ func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) { | |||
| 90 | 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 | 109 | // notifyStateChanged triggers the state changed callback if set. | |
| 94 | 110 | func (c *WorkspaceCoordinator) notifyStateChanged() { | |
| 95 | 111 | if c.onStateChanged != nil { | |
| @@ -717,52 +733,8 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||
| 717 | 733 | return nil | |
| 718 | 734 | } | |
| 719 | 735 | ||
| 720 | - | // BEFORE domain changes: capture widget info for incremental close | |
| 721 | - | var parentNode *entity.PaneNode | |
| 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 | - | } | |
| 736 | + | // BEFORE domain changes: capture incremental close context. | |
| 737 | + | closeCtx := c.captureIncrementalCloseContext(wsView, activePane) | |
| 766 | 738 | ||
| 767 | 739 | // Now do domain changes | |
| 768 | 740 | _, err := c.panesUC.Close(ctx, ws, activePane) | |
| @@ -776,13 +748,11 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||
| 776 | 748 | wsView, | |
| 777 | 749 | ws, | |
| 778 | 750 | closingPaneID, | |
| 779 | - | parentNode, | |
| 780 | - | siblingNode, | |
| 781 | - | grandparentNode, | |
| 782 | - | parentWidget, | |
| 783 | - | siblingIsStartChild, | |
| 784 | - | parentIsStartInGrand, | |
| 751 | + | closeCtx, | |
| 785 | 752 | ) | |
| 753 | + | if c.onPaneClosed != nil { | |
| 754 | + | c.onPaneClosed(closingPaneID) | |
| 755 | + | } | |
| 786 | 756 | ||
| 787 | 757 | // Notify state change for session snapshots | |
| 788 | 758 | c.notifyStateChanged() | |
| @@ -824,35 +794,8 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity. | |||
| 824 | 794 | return nil | |
| 825 | 795 | } | |
| 826 | 796 | ||
| 827 | - | // BEFORE domain changes: capture widget info for incremental close | |
| 828 | - | var parentNode *entity.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 | - | } | |
| 797 | + | // BEFORE domain changes: capture incremental close context. | |
| 798 | + | closeCtx := c.captureIncrementalCloseContext(wsView, paneNode) | |
| 856 | 799 | ||
| 857 | 800 | // Now do domain changes | |
| 858 | 801 | _, err := c.panesUC.Close(ctx, ws, paneNode) | |
| @@ -866,13 +809,11 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity. | |||
| 866 | 809 | wsView, | |
| 867 | 810 | ws, | |
| 868 | 811 | paneID, | |
| 869 | - | parentNode, | |
| 870 | - | siblingNode, | |
| 871 | - | grandparentNode, | |
| 872 | - | parentWidget, | |
| 873 | - | siblingIsStartChild, | |
| 874 | - | parentIsStartInGrand, | |
| 812 | + | closeCtx, | |
| 875 | 813 | ) | |
| 814 | + | if c.onPaneClosed != nil { | |
| 815 | + | c.onPaneClosed(paneID) | |
| 816 | + | } | |
| 876 | 817 | ||
| 877 | 818 | // Notify state change for session snapshots | |
| 878 | 819 | c.notifyStateChanged() | |
| @@ -894,6 +835,16 @@ func (c *WorkspaceCoordinator) doIncrementalClose( | |||
| 894 | 835 | parentIsStartInGrand bool, // true if parent is start/left child in grandparent | |
| 895 | 836 | ) error { | |
| 896 | 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 | 848 | tr := wsView.TreeRenderer() | |
| 898 | 849 | if tr == nil { | |
| 899 | 850 | return fmt.Errorf("tree renderer not available") | |
| @@ -1015,12 +966,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||
| 1015 | 966 | wsView *component.WorkspaceView, | |
| 1016 | 967 | ws *entity.Workspace, | |
| 1017 | 968 | paneID entity.PaneID, | |
| 1018 | - | parentNode *entity.PaneNode, | |
| 1019 | - | siblingNode *entity.PaneNode, | |
| 1020 | - | grandparentNode *entity.PaneNode, | |
| 1021 | - | parentWidget layout.Widget, | |
| 1022 | - | siblingIsStartChild bool, | |
| 1023 | - | parentIsStartInGrand bool, | |
| 969 | + | closeCtx incrementalCloseContext, | |
| 1024 | 970 | ) { | |
| 1025 | 971 | if wsView == nil { | |
| 1026 | 972 | return | |
| @@ -1028,29 +974,40 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||
| 1028 | 974 | ||
| 1029 | 975 | log := logging.FromContext(ctx) | |
| 1030 | 976 | ||
| 1031 | - | if parentNode != nil { | |
| 977 | + | if closeCtx.parentNode != nil { | |
| 1032 | 978 | var closeErr error | |
| 1033 | 979 | ||
| 1034 | - | if parentNode.IsStacked { | |
| 1035 | - | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode) | |
| 1036 | - | } else if siblingNode != nil && parentWidget != nil { | |
| 980 | + | if closeCtx.parentNode.IsStacked { | |
| 981 | + | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, closeCtx.parentNode) | |
| 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 | 985 | closeErr = c.doIncrementalClose( | |
| 1038 | 986 | ctx, | |
| 1039 | 987 | wsView, | |
| 1040 | 988 | paneID, | |
| 1041 | - | siblingNode, | |
| 1042 | - | parentNode, | |
| 1043 | - | grandparentNode, | |
| 1044 | - | parentWidget, | |
| 1045 | - | siblingIsStartChild, | |
| 1046 | - | parentIsStartInGrand, | |
| 989 | + | closeCtx.siblingNode, | |
| 990 | + | closeCtx.parentNode, | |
| 991 | + | closeCtx.grandparentNode, | |
| 992 | + | closeCtx.parentWidget, | |
| 993 | + | closeCtx.siblingIsStartChild, | |
| 994 | + | closeCtx.parentIsStartInGrand, | |
| 1047 | 995 | ) | |
| 1048 | 996 | } else { | |
| 1049 | 997 | closeErr = fmt.Errorf("missing context for incremental close") | |
| 1050 | 998 | } | |
| 1051 | 999 | ||
| 1052 | 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 | 1011 | if err := wsView.Rebuild(ctx); err != nil { | |
| 1055 | 1012 | log.Error().Err(err).Msg("failed to rebuild workspace view") | |
| 1056 | 1013 | } | |
| @@ -1073,6 +1030,113 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||
| 1073 | 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 | 1140 | // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree. | |
| 1077 | 1141 | func (c *WorkspaceCoordinator) doIncrementalStackClose( | |
| 1078 | 1142 | ctx context.Context, | |
| @@ -1559,6 +1623,18 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e | |||
| 1559 | 1623 | } | |
| 1560 | 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 | 1638 | // Update StackedView active index | |
| 1563 | 1639 | if err := sv.SetActive(ctx, clickedIndex); err != nil { | |
| 1564 | 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 | 18 | // HoverCallback is called when a pane should receive focus from hover. | |
| 19 | 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 | 24 | // HoverHandler handles mouse hover events for focus-follows-mouse behavior. | |
| 22 | 25 | // It uses a debounce timer to avoid rapid focus switches. | |
| 23 | 26 | type HoverHandler struct { | |
| 24 | 27 | motionCtrl *gtk.EventControllerMotion | |
| 25 | 28 | paneID entity.PaneID | |
| 26 | 29 | onEnter HoverCallback | |
| 30 | + | onMotion MotionCallback | |
| 27 | 31 | ||
| 28 | 32 | // Callback retention: must stay reachable by Go GC. | |
| 29 | - | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 30 | - | leaveCb func(gtk.EventControllerMotion) | |
| 33 | + | enterCb func(gtk.EventControllerMotion, float64, float64) | |
| 34 | + | leaveCb func(gtk.EventControllerMotion) | |
| 35 | + | motionCb func(gtk.EventControllerMotion, float64, float64) | |
| 31 | 36 | ||
| 32 | 37 | timer *time.Timer | |
| 33 | 38 | timerMu sync.Mutex | |
| @@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) { | |||
| 52 | 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 | 67 | // AttachTo attaches the hover handler to a GTK widget. | |
| 56 | 68 | func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |
| 57 | 69 | log := logging.FromContext(h.ctx) | |
| @@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||
| 79 | 91 | } | |
| 80 | 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 | 104 | // Add controller to widget | |
| 83 | 105 | widget.AddController(&h.motionCtrl.EventController) | |
| 84 | 106 | ||
| @@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() { | |||
| 143 | 165 | h.motionCtrl = nil | |
| 144 | 166 | h.enterCb = nil | |
| 145 | 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 | 159 | func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) } | |
| 160 | 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 | 163 | cb := func(_ gtk.Widget) { | |
| 164 | 164 | callback() | |
| 165 | 165 | } | |
| 166 | 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 | 170 | cb := func(_ gobject.Object, _ uintptr) { | |
| 171 | 171 | callback() | |
| 172 | 172 | } | |
| @@ -539,7 +539,7 @@ func (btn *gtkButton) GetChild() Widget { | |||
| 539 | 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 | 543 | cb := func(_ gtk.Button) { | |
| 544 | 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 | 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 | 1489 | ret := _mock.Called(callback) | |
| 1490 | 1490 | ||
| 1491 | 1491 | if len(ret) == 0 { | |
| 1492 | 1492 | panic("no return value specified for ConnectMap") | |
| 1493 | 1493 | } | |
| 1494 | 1494 | ||
| 1495 | - | var r0 uint32 | |
| 1496 | - | if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok { | |
| 1495 | + | var r0 uint | |
| 1496 | + | if returnFunc, ok := ret.Get(0).(func(func()) uint); ok { | |
| 1497 | 1497 | r0 = returnFunc(callback) | |
| 1498 | 1498 | } else { | |
| 1499 | - | r0 = ret.Get(0).(uint32) | |
| 1499 | + | r0 = ret.Get(0).(uint) | |
| 1500 | 1500 | } | |
| 1501 | 1501 | return r0 | |
| 1502 | 1502 | } | |
| @@ -1525,29 +1525,29 @@ func (_c *MockPanedWidget_ConnectMap_Call) Run(run func(callback func())) *MockP | |||
| 1525 | 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 | 1529 | _c.Call.Return(v) | |
| 1530 | 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 | 1534 | _c.Call.Return(run) | |
| 1535 | 1535 | return _c | |
| 1536 | 1536 | } | |
| 1537 | 1537 | ||
| 1538 | 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 | 1540 | ret := _mock.Called(callback) | |
| 1541 | 1541 | ||
| 1542 | 1542 | if len(ret) == 0 { | |
| 1543 | 1543 | panic("no return value specified for ConnectNotifyPosition") | |
| 1544 | 1544 | } | |
| 1545 | 1545 | ||
| 1546 | - | var r0 uint32 | |
| 1547 | - | if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok { | |
| 1546 | + | var r0 uint | |
| 1547 | + | if returnFunc, ok := ret.Get(0).(func(func()) uint); ok { | |
| 1548 | 1548 | r0 = returnFunc(callback) | |
| 1549 | 1549 | } else { | |
| 1550 | - | r0 = ret.Get(0).(uint32) | |
| 1550 | + | r0 = ret.Get(0).(uint) | |
| 1551 | 1551 | } | |
| 1552 | 1552 | return r0 | |
| 1553 | 1553 | } | |
| @@ -1576,12 +1576,12 @@ func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func | |||
| 1576 | 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 | 1580 | _c.Call.Return(v) | |
| 1581 | 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 | 1585 | _c.Call.Return(run) | |
| 1586 | 1586 | return _c | |
| 1587 | 1587 | } | |
| @@ -8007,18 +8007,18 @@ func (_c *MockButtonWidget_ComputePoint_Call) RunAndReturn(run func(target layou | |||
| 8007 | 8007 | } | |
| 8008 | 8008 | ||
| 8009 | 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 | 8011 | ret := _mock.Called(callback) | |
| 8012 | 8012 | ||
| 8013 | 8013 | if len(ret) == 0 { | |
| 8014 | 8014 | panic("no return value specified for ConnectClicked") | |
| 8015 | 8015 | } | |
| 8016 | 8016 | ||
| 8017 | - | var r0 uint32 | |
| 8018 | - | if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok { | |
| 8017 | + | var r0 uint | |
| 8018 | + | if returnFunc, ok := ret.Get(0).(func(func()) uint); ok { | |
| 8019 | 8019 | r0 = returnFunc(callback) | |
| 8020 | 8020 | } else { | |
| 8021 | - | r0 = ret.Get(0).(uint32) | |
| 8021 | + | r0 = ret.Get(0).(uint) | |
| 8022 | 8022 | } | |
| 8023 | 8023 | return r0 | |
| 8024 | 8024 | } | |
| @@ -8047,12 +8047,12 @@ func (_c *MockButtonWidget_ConnectClicked_Call) Run(run func(callback func())) * | |||
| 8047 | 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 | 8051 | _c.Call.Return(v) | |
| 8052 | 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 | 8056 | _c.Call.Return(run) | |
| 8057 | 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 | 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 | 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 | 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 | 35 | isActive bool | |
| 36 | 36 | ||
| 37 | 37 | // Signal handler IDs for cleanup on removal | |
| 38 | - | closeClickSignalID uint32 | |
| 38 | + | closeClickSignalID uint | |
| 39 | 39 | closeButton ButtonWidget // stored for signal disconnection | |
| 40 | 40 | ||
| 41 | 41 | // Retained callback for GestureClick to prevent GC | |
| @@ -183,7 +183,7 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa | |||
| 183 | 183 | // Returns the retained callback (to prevent GC) and close signal ID for disconnection. | |
| 184 | 184 | func (sv *StackedView) connectTitleBarHandlers( | |
| 185 | 185 | tb titleBarComponents, paneID string, | |
| 186 | - | ) (titleClickCallback interface{}, closeSignalID uint32) { | |
| 186 | + | ) (titleClickCallback interface{}, closeSignalID uint) { | |
| 187 | 187 | // Connect title bar click handler using GestureClick | |
| 188 | 188 | // This prevents event propagation issues with nested buttons | |
| 189 | 189 | clickCtrl := gtk.NewGestureClick() | |
| @@ -259,7 +259,7 @@ func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) { | |||
| 259 | 259 | ||
| 260 | 260 | // disconnectButtonSignal safely disconnects a signal from a button widget. | |
| 261 | 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 | 263 | if btn == nil || signalID == 0 { | |
| 264 | 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 | 70 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |
| 71 | 71 | ||
| 72 | 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 | 75 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |
| 76 | 76 | mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe() | |
| @@ -593,7 +593,7 @@ func setupInsertPaneMocks( | |||
| 593 | 593 | mockTitleBar.EXPECT().AddController(mock.Anything).Once() | |
| 594 | 594 | ||
| 595 | 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 | 598 | // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests | |
| 599 | 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 | 122 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 123 | 123 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 124 | 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 | 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 | 128 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 129 | 129 | ||
| 130 | 130 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | |
| @@ -172,9 +172,9 @@ func TestBuild_VerticalSplit(t *testing.T) { | |||
| 172 | 172 | mockBottomStackBox.EXPECT().SetVisible(true).Once() | |
| 173 | 173 | mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once() | |
| 174 | 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 | 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 | 178 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 179 | 179 | ||
| 180 | 180 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | |
| @@ -241,9 +241,9 @@ func TestBuild_NestedSplits(t *testing.T) { | |||
| 241 | 241 | mockStackBox2.EXPECT().SetVisible(true).Once() | |
| 242 | 242 | mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once() | |
| 243 | 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 | 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 | 247 | mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 248 | 248 | ||
| 249 | 249 | // Outer split (horizontal) gets inner paned and pane3 | |
| @@ -255,9 +255,9 @@ func TestBuild_NestedSplits(t *testing.T) { | |||
| 255 | 255 | mockStackBox3.EXPECT().SetVisible(true).Once() | |
| 256 | 256 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | |
| 257 | 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 | 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 | 261 | mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 262 | 262 | ||
| 263 | 263 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | |
| @@ -417,9 +417,9 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) { | |||
| 417 | 417 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 418 | 418 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 419 | 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 | 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 | 423 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 424 | 424 | ||
| 425 | 425 | renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory) | |
| @@ -542,9 +542,9 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||
| 542 | 542 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 543 | 543 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 544 | 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 | 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 | 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 | 95 | GetShrinkEndChild() bool | |
| 96 | 96 | ||
| 97 | 97 | // Signals | |
| 98 | - | ConnectMap(callback func()) uint32 | |
| 99 | - | ConnectNotifyPosition(callback func()) uint32 | |
| 98 | + | ConnectMap(callback func()) uint | |
| 99 | + | ConnectNotifyPosition(callback func()) uint | |
| 100 | 100 | ||
| 101 | 101 | // Tick callback for frame-based updates (returns signal ID) | |
| 102 | 102 | // Callback returns true to continue, false to stop | |
| @@ -182,7 +182,7 @@ type ButtonWidget interface { | |||
| 182 | 182 | SetIconName(iconName string) | |
| 183 | 183 | ||
| 184 | 184 | // Connect click handler, returns signal ID for disconnection | |
| 185 | - | ConnectClicked(callback func()) uint32 | |
| 185 | + | ConnectClicked(callback func()) uint | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | 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 | 527 | min-width: 32px; | |
| 528 | 528 | min-height: 32px; | |
| 529 | 529 | color: var(--muted); | |
| 530 | + | opacity: 0.4; | |
| 530 | 531 | } | |
| 531 | 532 | ||
| 532 | 533 | .loading-skeleton-logo { | |
| 533 | 534 | opacity: 0.08; | |
| 534 | - | min-width: 512px; | |
| 535 | - | min-height: 512px; | |
| 535 | + | min-width: 256px; | |
| 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 | 545 | /* Pane border - default transparent */ | |