detached 113a59f

Merge pull request #162 from bnema/feat/stability-performance-multi-agent-2026-02-07

feat: stability/performance follow-ups (clean restart)
commit
113a59f0acb49f31c217c6f86c380c3966cba157
author
bnema · · 2026-02-07 11:51 UTC
committer
GitHub · · 2026-02-07 11:51 UTC
merge of
01bb10e + 989c490 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
90 files · +5831 -907
M .gitignore +3 -0
@@ -107,3 +107,6 @@ aur/*/*.pkg.tar.zst
107107 aur/*/LICENSE
108108 aur/*/dev.bnema.Dumber.desktop
109109 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 @@
22
33 All notable changes to this project will be documented in this file.
44
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+
556 ## [0.26.1] - 2026-01-25
657
758 ### Fixed
M cmd/dumber/main.go +2 -0
@@ -28,6 +28,7 @@ import (
2828 "github.com/bnema/dumber/internal/infrastructure/xdg"
2929 "github.com/bnema/dumber/internal/logging"
3030 "github.com/bnema/dumber/internal/ui"
31+ "github.com/bnema/dumber/internal/ui/component"
3132 "github.com/bnema/dumber/internal/ui/theme"
3233 "github.com/rs/zerolog"
3334 )
@@ -71,6 +72,7 @@ func main() {
7172
7273 func runGUI() int {
7374 runtime.LockOSThread()
75+ component.SetSkeletonVersion(version)
7476 timer := bootstrap.NewStartupTimer()
7577
7678 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
432432 [media]
433433 hardware_decoding = "auto" # HW preferred, SW fallback
434434 prefer_av1 = false # Let site choose codec
435-show_diagnostics = false # Enable for debugging
435+show_diagnostics = false # Keep off for daily use
436436 force_vsync = false # Let compositor handle VSync
437437 gl_rendering_mode = "auto" # GStreamer picks best GL API
438438 gstreamer_debug_level = 0 # Increase to 3-5 for debugging
439439 ```
440440
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+
441452 **Diagnostics CLI:**
442453 ```bash
443454 # Check GStreamer plugins and VA-API status
@@ -529,7 +540,7 @@ Performance profiles provide preset configurations for WebKitGTK tuning. These s
529540
530541 | Profile | Description | Use Case |
531542 |---------|-------------|----------|
532-| `default` | No tuning, uses WebKit defaults | Normal browsing, most users |
543+| `default` | No tuning, uses WebKit defaults | Compatibility baseline, troubleshooting |
533544 | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |
534545 | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |
535546 | `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
549560 **Example:**
550561 ```toml
551562 [performance]
552-profile = "default" # Most users - no tuning needed
563+profile = "default" # WebKit baseline default
553564
554-# For low-RAM systems:
555-# profile = "lite"
565+# For WebKit baseline behavior:
566+# profile = "default"
556567
557568 # For heavy pages (GitHub PRs, complex web apps):
558569 # profile = "max"
M docs/reference/index.md +1 -0
@@ -6,6 +6,7 @@
66 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
77 - [Keybindings](./keybindings.md) - All keyboard shortcuts
88 - [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
910
1011 ## Related
1112
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
2929
3030 **Solution:**
3131 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+
3343 ```toml
3444 # ~/.config/dumber/config.toml
3545 [media]
3646 hardware_decoding = "auto" # Try "disable" if issues persist
3747 ```
38-3. Install VA-API drivers:
48+
49+4. Install VA-API drivers:
3950 - AMD: `libva-mesa-driver`
4051 - Intel: `intel-media-driver`
4152 - 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+ ```
4260
4361 ### Screen flickering on Wayland
4462
M go.mod +3 -3
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.9
6+ github.com/bnema/puregotk-webkit v0.0.10
77 github.com/charmbracelet/bubbles v0.21.1
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,13 +22,13 @@ require (
2222 gopkg.in/yaml.v3 v3.0.1
2323 )
2424
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
2626
2727 require (
2828 github.com/atotto/clipboard v0.1.4 // indirect
2929 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
3030 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
3232 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
3333 github.com/charmbracelet/x/term v0.2.2 // indirect
3434 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)
12811281 return _c
12821282 }
12831283
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+
12841443 // 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.
12851444 // The first argument is typically a *testing.T value.
12861445 func NewMockFontDetector(t interface {
M internal/application/port/updater.go +4 -0
@@ -3,10 +3,14 @@ package port
33
44 import (
55 "context"
6+ "errors"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 )
910
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+
1014 // UpdateChecker checks for available updates from a remote source.
1115 type UpdateChecker interface {
1216 // 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
33
44 import "context"
55
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+
615 // MemoryPressureConfig holds memory pressure settings for a WebKit process.
716 // Zero values mean "use WebKit defaults".
817 type MemoryPressureConfig struct {
@@ -42,6 +51,14 @@ type WebKitContextOptions struct {
4251 // CacheDir is the directory for cache data.
4352 CacheDir string
4453
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+
4562 // WebProcessMemory configures memory pressure for web processes.
4663 // nil means use WebKit defaults.
4764 WebProcessMemory *MemoryPressureConfig
M internal/application/port/webview.go +16 -4
@@ -52,6 +52,16 @@ type WebViewState struct {
5252 ZoomLevel float64
5353 }
5454
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+
5565 // PopupRequest contains metadata about a popup window request.
5666 type PopupRequest struct {
5767 TargetURI string
@@ -92,6 +102,8 @@ type WebViewCallbacks struct {
92102 // OnLinkHover is called when hovering over a link, image, or media element.
93103 // The uri parameter contains the target URL, or empty string when leaving.
94104 OnLinkHover func(uri string)
105+ // OnWebProcessTerminated is called when the web process exits unexpectedly.
106+ OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string)
95107 }
96108
97109 // FindOptions configures search behavior.
@@ -111,10 +123,10 @@ type FindController interface {
111123 GetSearchText() string
112124
113125 // 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)
118130 }
119131
120132 // 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
157157 return nil
158158 }
159159
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 ""
171165 }
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+ )
176175 }
177176
178177 // findHistorySuggestion finds the best matching history entry for the input.
@@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input
189188 return nil
190189 }
191190
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 ""
203196 }
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+ )
208206 }
209207
210208 // findBangSuggestion finds the best matching bang shortcut for the input.
@@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str
239237 }
240238
241239 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,
245246 }
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
253268 }
269+ urls = append(urls, url)
270+ titles = append(titles, titleAt(i))
271+ }
272+ if len(urls) == 0 {
273+ return nil
254274 }
255275
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+ }
257290 }
258291
259292 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) {
5555 assert.True(t, output.Found)
5656 assert.NotNil(t, output.Suggestion)
5757 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)
6060 assert.Equal(t, "My Repo", output.Suggestion.Title)
6161 }
6262
@@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) {
167167 assert.Equal(t, "a.dev", suggestion.Suffix)
168168 }
169169
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+
170189 func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {
171190 ctx := testContext()
172191
M internal/application/usecase/check_update.go +10 -0
@@ -2,6 +2,7 @@ package usecase
22
33 import (
44 "context"
5+ "errors"
56
67 "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/domain/build"
@@ -53,6 +54,15 @@ func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) (
5354
5455 info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version)
5556 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+ }
5666 log.Warn().Err(err).Msg("update check failed")
5767 return nil, err
5868 }
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 {
4343 ctx context.Context
4444
4545 controller port.FindController
46- signalIDs []uint32
46+ signalIDs []uint
4747
4848 query string
4949 matchCount uint
M internal/application/usecase/navigate.go +233 -52
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "net/url"
67 "strings"
78 "sync"
89 "time"
@@ -20,12 +21,32 @@ const (
2021
2122 // logURLMaxLen is the max length for URLs in log messages.
2223 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
2330 )
2431
2532 // historyRecord holds data for async history recording.
2633 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
2950 }
3051
3152 // NavigateUseCase handles URL navigation with history recording and zoom application.
@@ -33,7 +54,9 @@ type NavigateUseCase struct {
3354 historyRepo repository.HistoryRepository
3455 zoomRepo repository.ZoomRepository
3556 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
3760
3861 // Async history recording
3962 historyQueue chan historyRecord
@@ -57,6 +80,7 @@ func NewNavigateUseCase(
5780 historyRepo: historyRepo,
5881 zoomRepo: zoomRepo,
5982 defaultZoom: defaultZoom,
83+ recentVisits: make(map[string]paneHistoryState),
6084 historyQueue: make(chan historyRecord, historyQueueSize),
6185 done: make(chan struct{}),
6286 ctx: context.Background(),
@@ -72,7 +96,9 @@ func NewNavigateUseCase(
7296 // Close shuts down the background history worker and drains any pending records.
7397 // This should be called when the application is shutting down.
7498 func (uc *NavigateUseCase) Close() {
75- close(uc.done)
99+ uc.closeOnce.Do(func() {
100+ close(uc.done)
101+ })
76102 uc.wg.Wait()
77103 }
78104
@@ -117,36 +143,74 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
117143 // This prevents inflation from redirects and rapid navigation.
118144 const historyDeduplicationWindow = 2 * time.Second
119145
146+// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
147+// are removed while canonicalizing URLs for history deduplication/storage.
148+const stripTrackingParamsForHistoryDedup = true
149+
120150 // RecordHistory queues a history entry for async recording.
121151 // This is non-blocking to avoid SQLite I/O on the GTK main thread.
122152 // 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) {
124154 log := logging.FromContext(ctx)
155+ rawURL = strings.TrimSpace(rawURL)
156+ if rawURL == "" {
157+ return
158+ }
125159
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)
128168
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
131169 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
137180 }
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()
139195
140196 // Non-blocking send to async queue
141197 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}:
144199 default:
145200 // 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")
147202 }
148203 }
149204
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+
150214 // historyWorker is a background goroutine that processes history records.
151215 // It drains the queue and persists records to the database without blocking the UI.
152216 func (uc *NavigateUseCase) historyWorker() {
@@ -156,22 +220,44 @@ func (uc *NavigateUseCase) historyWorker() {
156220 Str("component", "history-worker").
157221 Logger()
158222
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+
159249 for {
160250 select {
161251 case record := <-uc.historyQueue:
162- uc.persistHistory(uc.ctx, record)
252+ pending[record.url] += record.visits
253+ case <-ticker.C:
254+ flushPending()
163255 case <-uc.done:
164- // Drain remaining records before shutdown
165256 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
175261 }
176262 }
177263 }
@@ -189,13 +275,33 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
189275 }
190276
191277 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+ }
195300 }
196301 } else {
197302 // Create new entry
198303 entry := entity.NewHistoryEntry(record.url, "")
304+ entry.VisitCount = int64(max(1, record.visits))
199305 if err := uc.historyRepo.Save(ctx, entry); err != nil {
200306 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
201307 }
@@ -203,14 +309,20 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
203309 }
204310
205311 // 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 {
207313 log := logging.FromContext(ctx)
208314
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")
212324
213- entry, err := uc.historyRepo.FindByURL(ctx, url)
325+ entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
214326 if err != nil {
215327 return fmt.Errorf("failed to find history entry: %w", err)
216328 }
@@ -219,7 +331,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
219331 // Entry doesn't exist - don't create it here
220332 // Initial navigation should have already created the entry
221333 // 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")
223335 return nil
224336 }
225337
@@ -250,20 +362,89 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
250362 return webview.Stop(ctx)
251363 }
252364
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+ }
264387 }
265- // For paths like "https://github.com/user/" - also strip
266- return strings.TrimSuffix(url, "/")
267388 }
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
269450 }
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 (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "strings"
910 "sync"
1011 "syscall"
1112 "time"
@@ -24,6 +25,7 @@ const (
2425 recentSessionsLimit = 200
2526 lockDirPerm = 0o755
2627 lockFilePerm = 0o600
28+ markerFilePerm = 0o644
2729 )
2830
2931 type BrowserSession struct {
@@ -95,6 +97,19 @@ func StartBrowserSession(
9597 // error handling during shutdown sequences where persistFn may be called multiple times.
9698 persistFn := func(persistCtx context.Context) error {
9799 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+
98113 saveCtx := corelogging.WithContext(context.Background(), logger)
99114 if err := sessionRepo.Save(saveCtx, session); err != nil {
100115 persistErr = fmt.Errorf("save session: %w", err)
@@ -105,6 +120,11 @@ func StartBrowserSession(
105120 Str("session_id", string(session.ID)).
106121 Str("type", string(session.Type)).
107122 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+ }
108128
109129 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110130 if lockErr != nil {
@@ -133,6 +153,13 @@ func StartBrowserSession(
133153 lp := lockPath
134154 lockMu.Unlock()
135155
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+
136163 if lf != nil {
137164 _ = unlockAndClose(lf)
138165 _ = os.Remove(lp)
@@ -263,6 +290,158 @@ func unlockAndClose(f *os.File) error {
263290 return f.Close()
264291 }
265292
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+
266445 // runSessionCleanupAsync performs stale session cleanup and old session pruning
267446 // in a background goroutine. This avoids blocking startup for non-critical tasks.
268447 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.
@@ -306,5 +485,73 @@ func runSessionCleanupAsync(
306485 log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
307486 }
308487 }
488+
489+ // Sweep paired startup+shutdown markers that are no longer needed.
490+ if lockDir != "" {
491+ sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)
492+ }
309493 }()
310494 }
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 {
4646 configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
4747 logging.Trace().Mark("render_env")
4848
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")
5054 wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
5155 if err != nil {
5256 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
@@ -97,10 +101,23 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo
97101 return perfSettings
98102 }
99103
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+
101116 wkOpts := port.WebKitContextOptions{
102- DataDir: dataDir,
103- CacheDir: cacheDir,
117+ DataDir: dataDir,
118+ CacheDir: cacheDir,
119+ CookiePolicy: cookiePolicy,
120+ ITPEnabled: itpEnabled,
104121 }
105122
106123 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 @@
11 // Package autocomplete provides domain types and logic for URL autocompletion.
22 package autocomplete
33
4-import "strings"
4+import (
5+ stdurl "net/url"
6+ "strings"
7+ "unicode/utf8"
8+)
59
610 // SuggestionSource indicates the origin of an autocomplete suggestion.
711 type SuggestionSource int
@@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin
7882
7983 return "", "", false
8084 }
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) {
266266 })
267267 }
268268 }
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 {
153153 SampleMemory: false,
154154 DebugFrames: false,
155155 },
156+ Privacy: PrivacyConfig{
157+ CookiePolicy: CookiePolicyNoThirdParty,
158+ ITPEnabled: true,
159+ },
156160 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
157161 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
158162 Workspace: WorkspaceConfig{
@@ -289,7 +293,7 @@ func DefaultConfig() *Config {
289293 Prefix: "",
290294 },
291295 Performance: PerformanceConfig{
292- Profile: ProfileDefault, // Use WebKit defaults, no tuning
296+ Profile: ProfileDefault, // Use WebKit defaults by default
293297 ZoomCacheSize: defaultZoomCacheSize,
294298 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
295299 // 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 {
177177 }
178178
179179 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) {
180190 switch strings.ToLower(string(config.Rendering.Mode)) {
181191 case "", string(RenderingModeAuto):
182192 config.Rendering.Mode = RenderingModeAuto
@@ -200,15 +210,35 @@ func normalizeConfig(config *Config) {
200210 default:
201211 config.Rendering.GSKRenderer = GSKRendererAuto
202212 }
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+}
203227
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):
207235 config.Appearance.ColorScheme = ThemeDefault
208236 default:
209237 config.Appearance.ColorScheme = ThemeDefault
210238 }
239+}
211240
241+func normalizeMedia(config *Config) {
212242 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
213243 case "", string(HardwareDecodingAuto):
214244 config.Media.HardwareDecodingMode = HardwareDecodingAuto
@@ -232,9 +262,6 @@ func normalizeConfig(config *Config) {
232262 default:
233263 config.Media.GLRenderingMode = GLRenderingModeAuto
234264 }
235-
236- config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
237- normalizePerformanceProfile(config)
238265 }
239266
240267 func normalizePerformanceProfile(config *Config) {
@@ -247,6 +274,8 @@ func normalizePerformanceProfile(config *Config) {
247274 config.Performance.Profile = ProfileMax
248275 case string(ProfileCustom):
249276 config.Performance.Profile = ProfileCustom
277+ default:
278+ config.Performance.Profile = ProfileDefault
250279 }
251280 }
252281
@@ -373,6 +402,7 @@ func (m *Manager) setDefaults() {
373402 m.setDebugDefaults(defaults)
374403 m.setAppearanceDefaults(defaults)
375404 m.setRenderingDefaults(defaults)
405+ m.setPrivacyDefaults(defaults)
376406 m.setWorkspaceDefaults(defaults)
377407 m.setContentFilteringDefaults(defaults)
378408 m.setClipboardDefaults(defaults)
@@ -447,6 +477,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) {
447477 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
448478 }
449479
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+
450485 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
451486 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
452487 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 {
2727 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
2828 // Rendering controls WebKit, GTK and compositor behavior.
2929 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"`
3032 // Media controls video playback and hardware acceleration
3133 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
3234 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
@@ -48,6 +50,18 @@ const (
4850 RenderingModeCPU RenderingMode = "cpu"
4951 )
5052
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+
5165 // GSKRendererMode controls the GTK Scene Kit renderer selection.
5266 type GSKRendererMode string
5367
@@ -103,6 +117,15 @@ type RenderingConfig struct {
103117 DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`
104118 }
105119
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+
106129 // HardwareDecodingMode controls video hardware acceleration.
107130 type HardwareDecodingMode string
108131
M internal/infrastructure/config/validation.go +13 -0
@@ -26,6 +26,7 @@ func validateConfig(config *Config) error {
2626 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
2727 validationErrors = append(validationErrors, validateOmnibox(config)...)
2828 validationErrors = append(validationErrors, validateRendering(config)...)
29+ validationErrors = append(validationErrors, validatePrivacy(config)...)
2930 validationErrors = append(validationErrors, validateColorScheme(config)...)
3031 validationErrors = append(validationErrors, validateSession(config)...)
3132 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
@@ -323,6 +324,18 @@ func validateRendering(config *Config) []string {
323324 return validationErrors
324325 }
325326
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+
326339 func validateColorScheme(config *Config) []string {
327340 switch config.Appearance.ColorScheme {
328341 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,
183183 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)
184184 }
185185
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")
189188 if err != nil {
190189 return "", 0, fmt.Errorf("failed to create temp file: %w", err)
191190 }
191+ tmpPath := tmpFile.Name()
192192
193- bytesWritten, err := io.Copy(file, resp.Body)
193+ bytesWritten, err := io.Copy(tmpFile, resp.Body)
194194 if err != nil {
195- _ = file.Close()
195+ _ = tmpFile.Close()
196196 _ = os.Remove(tmpPath)
197197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
198198 }
199- if err := file.Close(); err != nil {
199+ if err := tmpFile.Close(); err != nil {
200200 _ = os.Remove(tmpPath)
201201 return "", 0, fmt.Errorf("failed to close temp file: %w", err)
202202 }
203203
204- // Atomic rename
205- if err := os.Rename(tmpPath, localPath); err != nil {
204+ if err := d.renameTempFile(tmpPath, localPath); err != nil {
206205 _ = os.Remove(tmpPath)
207- return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
206+ return "", 0, err
208207 }
209208
210209 return localPath, bytesWritten, nil
211210 }
212211
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+
213226 // downloadAndCacheManifest downloads and caches the manifest.
214227 func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
215228 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
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "os"
89 "path/filepath"
10+ "strings"
911 "sync"
1012 "sync/atomic"
1113 "time"
@@ -17,12 +19,17 @@ import (
1719 const (
1820 storeDirPerm = 0o755
1921 jsonDirPerm = 0o755
22+ quarantinePerm = 0o755
2023 mergedFilePerm = 0o644
2124
2225 // CacheMaxAge is the maximum age of the filter cache before it's considered stale.
2326 CacheMaxAge = 24 * time.Hour
2427 )
2528
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+
2633 // Manager orchestrates the content filter lifecycle.
2734 // It handles downloading, compiling, loading, and applying filters to WebViews.
2835 type Manager struct {
@@ -140,86 +147,125 @@ func (m *Manager) LoadAsync(ctx context.Context) {
140147 return
141148 }
142149
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+}
180152
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()
192157
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+ }
194162
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+ }
202167
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+}
210170
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()
214175
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() {
215246 version := m.getCachedVersion()
216247 m.setStatus(FilterStatus{
217248 State: StateActive,
218- Message: "Filters active",
249+ Message: "Filters active (update skipped)",
219250 Version: version,
220251 })
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
223269 }
224270
225271 // mergeJSONFiles combines multiple JSON rule files into one.
@@ -345,16 +391,27 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
345391 if err != nil {
346392 return fmt.Errorf("failed to download filter update: %w", err)
347393 }
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+ }
348399
349400 // Merge and compile
350401 mergedPath, err := m.mergeJSONFiles(ctx, paths)
351402 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)
353405 }
354406
355407 filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
356408 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)
358415 }
359416
360417 m.filterMu.Lock()
@@ -372,6 +429,109 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
372429 return nil
373430 }
374431
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+
375535 // Enable enables content filtering.
376536 func (m *Manager) Enable(ctx context.Context) {
377537 log := logging.FromContext(ctx).With().
M internal/infrastructure/filtering/manager_test.go +117 -4
@@ -2,6 +2,7 @@ package filtering_test
22
33 import (
44 "context"
5+ "errors"
56 "os"
67 "path/filepath"
78 "sync"
@@ -11,6 +12,7 @@ import (
1112 "github.com/bnema/dumber/internal/infrastructure/filtering"
1213 "github.com/bnema/dumber/internal/infrastructure/filtering/mocks"
1314 "github.com/bnema/dumber/internal/logging"
15+ "github.com/bnema/puregotk-webkit/webkit"
1416 "github.com/stretchr/testify/assert"
1517 "github.com/stretchr/testify/mock"
1618 "github.com/stretchr/testify/require"
@@ -54,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
5456
5557 mockStore.EXPECT().
5658 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
57- Return(nil, nil)
59+ Return(&webkit.UserContentFilter{}, nil)
5860
5961 mockDownloader.EXPECT().
6062 GetCachedManifest().
@@ -144,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
144146 // Compile should be called
145147 mockStore.EXPECT().
146148 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
147- Return(nil, nil)
149+ Return(&webkit.UserContentFilter{}, nil)
148150
149151 // GetCachedManifest for version
150152 mockDownloader.EXPECT().
@@ -241,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
241243 // Compile called
242244 mockStore.EXPECT().
243245 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
244- Return(nil, nil)
246+ Return(&webkit.UserContentFilter{}, nil)
245247
246248 // GetCachedManifest for version
247249 mockDownloader.EXPECT().
@@ -451,7 +453,7 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
451453 Return([]string{jsonFile}, nil)
452454 mockStore.EXPECT().
453455 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
454- Return(nil, nil)
456+ Return(&webkit.UserContentFilter{}, nil)
455457 mockDownloader.EXPECT().
456458 GetCachedManifest().
457459 Return(&filtering.Manifest{Version: "test"}, nil)
@@ -494,3 +496,114 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
494496 defer mu.Unlock()
495497 assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice")
496498 }
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 (
66 "errors"
77 "fmt"
88 "strings"
9+ "sync/atomic"
910 "time"
11+ "unicode"
1012
1113 "github.com/bnema/dumber/internal/domain/entity"
1214 "github.com/bnema/dumber/internal/domain/repository"
@@ -16,6 +18,8 @@ import (
1618
1719 const logURLMaxLen = 60
1820
21+var expectedFTSFailureCount atomic.Uint64
22+
1923 type historyRepo struct {
2024 queries *sqlc.Queries
2125 }
@@ -68,28 +72,15 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
6872 }
6973
7074 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)
8476 if len(words) == 0 {
8577 return []entity.HistoryMatch{}, nil
8678 }
8779
8880 // Build FTS5 query: "word1* word2*" (implicit AND between terms)
89- // Sanitize each word to remove FTS5 special characters
9081 parts := make([]string, len(words))
9182 for i, word := range words {
92- parts[i] = sanitizeFTS5Word(word) + "*"
83+ parts[i] = word + "*"
9384 }
9485 ftsQuery := strings.Join(parts, " ")
9586
@@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
9788 // Use domain-boosted query for URL search to prioritize domain matches
9889 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
9990 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},
10192 Query: ftsQuery,
10293 Limit: int64(limit),
10394 })
10495 if urlErr != nil {
105- log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")
96+ logFTSSearchError(ctx, "url", query, urlErr)
10697 urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}
10798 }
10899
@@ -111,7 +102,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
111102 Limit: int64(limit),
112103 })
113104 if titleErr != nil {
114- log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")
105+ logFTSSearchError(ctx, "title", query, titleErr)
115106 titleRows = []sqlc.History{}
116107 }
117108
@@ -163,23 +154,77 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
163154 }
164155 }
165156
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.
169195 func sanitizeFTS5Word(word string) string {
170- // Remove characters that have special meaning in FTS5
171196 var result strings.Builder
197+ result.Grow(len(word))
172198 for _, r := range word {
173- switch r {
174- case '"', '*', '(', ')', ':', '^', '-', '/':
175- // Skip FTS5 special characters
176- default:
199+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
177200 result.WriteRune(r)
178201 }
179202 }
180203 return result.String()
181204 }
182205
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+
183228 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
184229 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
185230 Limit: int64(limit),
@@ -266,6 +311,20 @@ func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error
266311 return r.queries.IncrementVisitCount(ctx, url)
267312 }
268313
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+
269328 func (r *historyRepo) Delete(ctx context.Context, id int64) error {
270329 return r.queries.DeleteHistoryByID(ctx, id)
271330 }
M internal/infrastructure/persistence/sqlite/history_repo_test.go +50 -0
@@ -429,6 +429,35 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
429429 assert.Empty(t, results)
430430 }
431431
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+
432461 func TestHistoryRepository_Search_NoResults(t *testing.T) {
433462 ctx := historyTestCtx()
434463 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -557,6 +586,27 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
557586 }
558587 }
559588
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+
560610 func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561611 ctx := historyTestCtx()
562612 dbPath := filepath.Join(t.TempDir(), "dumber.db")
M internal/infrastructure/persistence/sqlite/lazy_repos.go +26 -0
@@ -18,6 +18,7 @@ package sqlite
1818 import (
1919 "context"
2020 "database/sql"
21+ "fmt"
2122 "sync"
2223 "time"
2324
@@ -114,6 +115,31 @@ func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url str
114115 return r.repo.IncrementVisitCount(ctx, url)
115116 }
116117
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+
117143 func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
118144 if err := r.init(ctx); err != nil {
119145 return err
M internal/infrastructure/persistence/sqlite/queries/history.sql +5 -0
@@ -19,6 +19,11 @@ UPDATE history
1919 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
2020 WHERE url = ?;
2121
22+-- name: IncrementVisitCountByDelta :exec
23+UPDATE history
24+SET visit_count = visit_count + ?, last_visited = CURRENT_TIMESTAMP
25+WHERE url = ?;
26+
2227 -- name: SearchHistory :many
2328 SELECT * FROM history
2429 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
M internal/infrastructure/persistence/sqlite/session_state_repo.go +27 -3
@@ -5,6 +5,7 @@ import (
55 "database/sql"
66 "encoding/json"
77 "errors"
8+ "fmt"
89
910 "github.com/bnema/dumber/internal/domain/entity"
1011 "github.com/bnema/dumber/internal/domain/repository"
@@ -13,12 +14,16 @@ import (
1314 )
1415
1516 type sessionStateRepo struct {
17+ db *sql.DB
1618 queries *sqlc.Queries
1719 }
1820
1921 // NewSessionStateRepository creates a new session state repository.
2022 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+ }
2227 }
2328
2429 // SaveSnapshot saves or updates a session state snapshot.
@@ -40,14 +45,33 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi
4045 Int("pane_count", state.CountPanes()).
4146 Msg("saving session state snapshot")
4247
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{
4460 SessionID: string(state.SessionID),
4561 StateJson: string(stateJSON),
4662 Version: int64(state.Version),
4763 TabCount: int64(len(state.Tabs)),
4864 PaneCount: int64(state.CountPanes()),
4965 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
5175 }
5276
5377 // 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 {
410410 return err
411411 }
412412
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+
413429 const SearchHistory = `-- name: SearchHistory :many
414430 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
415431 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
@@ -64,6 +64,7 @@ type Querier interface {
6464 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
6565 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
6666 IncrementVisitCount(ctx context.Context, url string) error
67+ IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
6768 InsertSession(ctx context.Context, arg InsertSessionParams) error
6869 IsWhitelisted(ctx context.Context, domain string) (int64, error)
6970 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
M internal/infrastructure/snapshot/service.go +113 -4
@@ -2,6 +2,9 @@ package snapshot
22
33 import (
44 "context"
5+ "errors"
6+ "fmt"
7+ "strings"
58 "sync"
69 "time"
710
@@ -10,11 +13,19 @@ import (
1013 "github.com/bnema/dumber/internal/logging"
1114 )
1215
16+const (
17+ defaultSnapshotIntervalMs = 5000
18+ maxFKRetries = 3
19+ fkRetryDelay = 100 * time.Millisecond
20+)
21+
1322 // Service handles debounced session state snapshots.
1423 type Service struct {
1524 snapshotUC *usecase.SnapshotSessionUseCase
1625 provider port.TabListProvider
1726 interval time.Duration
27+ retries int
28+ retryDelay time.Duration
1829
1930 mu sync.Mutex
2031 timer *time.Timer
@@ -31,12 +42,14 @@ func NewService(
3142 intervalMs int,
3243 ) *Service {
3344 if intervalMs <= 0 {
34- intervalMs = 5000 // Default 5 seconds
45+ intervalMs = defaultSnapshotIntervalMs
3546 }
3647 return &Service{
3748 snapshotUC: snapshotUC,
3849 provider: provider,
3950 interval: time.Duration(intervalMs) * time.Millisecond,
51+ retries: maxFKRetries,
52+ retryDelay: fkRetryDelay,
4053 }
4154 }
4255
@@ -54,8 +67,24 @@ func (s *Service) Start(ctx context.Context) {
5467 // to avoid FK constraint violations.
5568 func (s *Service) SetReady() {
5669 s.mu.Lock()
57- defer s.mu.Unlock()
70+ if s.timer != nil {
71+ s.timer.Stop()
72+ s.timer = nil
73+ }
5874 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+ }()
5988 }
6089
6190 // Stop stops the service and saves final state.
@@ -135,11 +164,91 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
135164 sessionID := s.provider.GetSessionID()
136165
137166 if sessionID == "" {
167+ s.markDirty()
138168 return nil
139169 }
140170
141- return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{
171+ if err := s.executeWithRetry(ctx, usecase.SnapshotInput{
142172 SessionID: sessionID,
143173 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")
145254 }
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 (
1212 "errors"
1313 "fmt"
1414 "io"
15+ "math/rand"
16+ "net"
1517 "net/http"
1618 "net/url"
1719 "os"
@@ -20,8 +22,10 @@ import (
2022 "runtime"
2123 "strconv"
2224 "strings"
25+ "syscall"
2326 "time"
2427
28+ "github.com/bnema/dumber/internal/application/port"
2529 "github.com/bnema/dumber/internal/domain/entity"
2630 "github.com/bnema/dumber/internal/infrastructure/env"
2731 "github.com/bnema/dumber/internal/logging"
@@ -58,6 +62,18 @@ const (
5862
5963 // Allowed GitHub download host.
6064 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
6177 )
6278
6379 // githubRelease represents the GitHub API response for a release.
@@ -70,13 +86,17 @@ type githubRelease struct {
7086
7187 // GitHubChecker implements UpdateChecker using the GitHub API.
7288 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
7492 }
7593
7694 // NewGitHubChecker creates a new GitHub-based update checker.
7795 func NewGitHubChecker() *GitHubChecker {
7896 return &GitHubChecker{
79- client: &http.Client{Timeout: apiTimeout},
97+ client: &http.Client{Timeout: apiTimeout},
98+ randInt63: rand.Int63n,
99+ sleep: waitForBackoff,
80100 }
81101 }
82102
@@ -115,13 +135,21 @@ func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion strin
115135 req.Header.Set("Accept", "application/vnd.github.v3+json")
116136 req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)
117137
118- resp, err := g.client.Do(req)
138+ resp, err := g.doRequestWithRetry(ctx, req)
119139 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+ }
120144 return nil, fmt.Errorf("failed to fetch latest release: %w", err)
121145 }
122146 defer func() { _ = resp.Body.Close() }()
123147
124148 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+ }
125153 return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)
126154 }
127155
@@ -214,16 +242,153 @@ func compareVersions(v1, v2 string) int {
214242
215243 // GitHubDownloader implements UpdateDownloader for GitHub releases.
216244 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
218248 }
219249
220250 // NewGitHubDownloader creates a new GitHub release downloader.
221251 func NewGitHubDownloader() *GitHubDownloader {
222252 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
224321 }
225322 }
226323
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+
227392 // validateDownloadURL ensures the URL is a valid GitHub releases URL.
228393 func validateDownloadURL(downloadURL string) error {
229394 parsed, err := url.Parse(downloadURL)
@@ -257,7 +422,7 @@ func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]strin
257422 }
258423 req.Header.Set("User-Agent", "dumber-browser")
259424
260- resp, err := g.client.Do(req)
425+ resp, err := g.doRequestWithRetry(ctx, req)
261426 if err != nil {
262427 return nil, fmt.Errorf("failed to fetch checksums: %w", err)
263428 }
@@ -362,7 +527,7 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
362527
363528 log.Debug().Str("url", downloadURL).Msg("downloading update")
364529
365- resp, err := g.client.Do(req)
530+ resp, err := g.doRequestWithRetry(ctx, req)
366531 if err != nil {
367532 return "", fmt.Errorf("failed to download: %w", err)
368533 }
@@ -423,6 +588,10 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
423588 return archivePath, nil
424589 }
425590
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+
426595 // sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
427596 func sanitizeTarPath(name, destDir string) (string, error) {
428597 // Clean the path to remove any . or .. components.
M internal/infrastructure/updater/github_test.go +141 -0
@@ -1,12 +1,20 @@
11 package updater
22
33 import (
4+ "context"
45 "crypto/sha256"
56 "encoding/hex"
7+ "errors"
8+ "io"
9+ "net/http"
610 "os"
711 "path/filepath"
812 "strings"
13+ "sync/atomic"
914 "testing"
15+ "time"
16+
17+ "github.com/bnema/dumber/internal/application/port"
1018 )
1119
1220 func TestCompareVersions(t *testing.T) {
@@ -355,7 +363,140 @@ func TestSizeLimits(t *testing.T) {
355363 }
356364 }
357365
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+
358485 // contains checks if substr is in s (helper for error message checks).
359486 func contains(s, substr string) bool {
360487 return strings.Contains(s, substr)
361488 }
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 {
3737 // This MUST be called before creating any WebViews to ensure they use persistent storage.
3838 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
3939 return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{
40- DataDir: dataDir,
41- CacheDir: cacheDir,
40+ DataDir: dataDir,
41+ CacheDir: cacheDir,
42+ CookiePolicy: port.WebKitCookiePolicyNoThirdParty,
43+ ITPEnabled: true,
4244 })
4345 }
4446
@@ -71,7 +73,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
7173
7274 // Create persistent network session FIRST
7375 // 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 {
7577 return nil, fmt.Errorf("failed to init network session: %w", err)
7678 }
7779
@@ -112,7 +114,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
112114 }
113115
114116 // initNetworkSession creates and configures the persistent network session.
115-func (c *WebKitContext) initNetworkSession() error {
117+func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error {
116118 // Create persistent network session
117119 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
118120 if session == nil {
@@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error {
158160
159161 cookiePath := filepath.Join(c.dataDir, "cookies.db")
160162 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)
162166
163- c.logger.Debug().
167+ c.logger.Info().
164168 Str("cookie_path", cookiePath).
169+ Str("cookie_policy", cookiePolicyLabel).
170+ Bool("itp_enabled", opts.ITPEnabled).
165171 Msg("cookie storage configured")
166172
167173 // Enable persistent credential storage
@@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error {
182188 return nil
183189 }
184190
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+
185204 // Context returns the shared WebContext.
186205 func (c *WebKitContext) Context() *webkit.WebContext {
187206 c.mu.RLock()
M internal/infrastructure/webkit/messaging.go +4 -4
@@ -47,7 +47,7 @@ type MessageRouter struct {
4747
4848 mu sync.RWMutex
4949 callbacks []interface{}
50- signals []uint32
50+ signals []uint
5151
5252 idMu sync.Mutex
5353 syncedIDs map[WebViewID]bool
@@ -121,7 +121,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal
121121 // It registers the script message handler in the MAIN world (not isolated).
122122 // WebKit's messageHandlers is only available in main world.
123123 // 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) {
125125 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
126126
127127 log.Debug().Msg("SetupMessageHandler called")
@@ -146,7 +146,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
146146
147147 // Connect to "script-message-received::dumber" with handler name as signal detail
148148 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")
150150
151151 r.mu.Lock()
152152 r.signals = append(r.signals, signalID)
@@ -164,7 +164,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
164164 log.Info().
165165 Str("handler", MessageHandlerName).
166166 Str("world", "main").
167- Uint32("signal_id", signalID).
167+ Uint64("signal_id", uint64(signalID)).
168168 Msg("script message handler connected")
169169
170170 return signalID, nil
M internal/infrastructure/webkit/pool.go +182 -39
@@ -3,6 +3,8 @@ package webkit
33 import (
44 "context"
55 "errors"
6+ "os"
7+ "strings"
68 "sync"
79 "sync/atomic"
810 "time"
@@ -31,6 +33,12 @@ type PoolConfig struct {
3133 PrewarmCount int
3234 // IdleTimeout is how long idle views stay in the pool before being destroyed.
3335 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
3442 }
3543
3644 // DefaultPoolConfig returns sensible defaults for the pool.
@@ -43,6 +51,12 @@ func DefaultPoolConfig() PoolConfig {
4351 }
4452 }
4553
54+const (
55+ reusePolicyOff = "off"
56+ reusePolicySafe = "safe"
57+ reusePolicyAggressive = "aggressive"
58+)
59+
4660 // WebViewPool manages a pool of pre-created WebViews for fast tab creation.
4761 type WebViewPool struct {
4862 wkCtx *WebKitContext
@@ -50,6 +64,7 @@ type WebViewPool struct {
5064 config PoolConfig
5165
5266 pool chan *WebView
67+ poolMu sync.Mutex
5368 injector *ContentInjector
5469 router *MessageRouter
5570 filterApplier FilterApplier // Optional content filter applier
@@ -59,6 +74,19 @@ type WebViewPool struct {
5974
6075 closed atomic.Bool
6176 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
6290 }
6391
6492 // NewWebViewPool creates a new WebView pool.
@@ -79,21 +107,28 @@ func NewWebViewPool(
79107 if cfg.MinSize > cfg.MaxSize {
80108 cfg.MinSize = cfg.MaxSize
81109 }
110+ cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy)
111+ cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy)
82112 if ctx == nil {
83113 ctx = context.Background()
84114 }
85115
86116 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,
93125 }
94126
95127 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")
97132
98133 return p
99134 }
@@ -113,6 +148,7 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
113148 // Acquire gets a WebView from the pool or creates a new one.
114149 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
115150 log := logging.FromContext(ctx)
151+ start := time.Now()
116152
117153 if p.closed.Load() {
118154 return nil, ErrPoolClosed
@@ -135,7 +171,15 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
135171 if p.filterApplier != nil {
136172 p.filterApplier.ApplyTo(ctx, wv.ucm)
137173 }
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")
139183 return wv, nil
140184 }
141185 // Fall through to create new if the pooled one was destroyed
@@ -145,7 +189,28 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
145189
146190 // Pool empty or pooled view was destroyed, create new
147191 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
149214 }
150215
151216 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
@@ -174,29 +239,52 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
174239 return wv, nil
175240 }
176241
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.
181244 func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
182245 log := logging.FromContext(ctx)
246+ start := time.Now()
183247
184248 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
185249 if wv != nil && !wv.IsDestroyed() {
186- wv.Destroy()
250+ wv.DestroyWithPolicy(p.terminatePolicy)
187251 }
252+ p.releaseDropped.Add(1)
188253 return
189254 }
190255
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")
200288 }
201289
202290 // PrewarmFirst creates exactly one WebView synchronously.
@@ -218,12 +306,11 @@ func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
218306 return err
219307 }
220308
221- select {
222- case p.pool <- wv:
309+ if p.tryPut(ctx, wv) {
223310 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
226312 }
313+ wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen)
227314 return nil
228315 }
229316
@@ -294,11 +381,10 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
294381 if err != nil {
295382 log.Warn().Err(err).Msg("failed to prewarm webview")
296383 } else {
297- select {
298- case p.pool <- wv:
384+ if p.tryPut(ctx, wv) {
299385 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)
302388 }
303389 }
304390
@@ -336,12 +422,11 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
336422 }
337423
338424 // Try to add to pool
339- select {
340- case p.pool <- wv:
425+ if p.tryPut(ctx, wv) {
341426 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
342- default:
427+ } else {
343428 // Pool full, destroy
344- wv.Destroy()
429+ wv.DestroyWithPolicy(p.terminatePolicy)
345430 }
346431
347432 // Small delay between creations to avoid overwhelming the system
@@ -387,11 +472,10 @@ refreshLoop:
387472 }
388473
389474 // Put back in pool
390- select {
391- case p.pool <- wv:
475+ if p.tryPut(ctx, wv) {
392476 refreshed++
393- default:
394- wv.Destroy()
477+ } else {
478+ wv.DestroyWithPolicy(p.terminatePolicy)
395479 }
396480 default:
397481 // Pool was modified during iteration, stop
@@ -417,7 +501,9 @@ func (p *WebViewPool) Close(ctx context.Context) {
417501 p.wg.Wait()
418502
419503 // Drain and destroy all pooled views
504+ p.poolMu.Lock()
420505 close(p.pool)
506+ p.poolMu.Unlock()
421507 for wv := range p.pool {
422508 if wv != nil && !wv.IsDestroyed() {
423509 wv.Destroy()
@@ -426,3 +512,60 @@ func (p *WebViewPool) Close(ctx context.Context) {
426512
427513 log.Debug().Msg("webview pool closed")
428514 }
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 (
55 "embed"
66 "encoding/json"
77 "fmt"
8+ "html"
89 "io/fs"
910 "mime"
1011 "net/http"
@@ -28,7 +29,9 @@ const (
2829 HomePath = "home"
2930 ConfigPath = "config"
3031 ErrorPath = "error"
32+ CrashPath = "crash"
3133 IndexHTML = "index.html"
34+ httpGET = "GET"
3235 )
3336
3437 // SchemeRequest represents a request to a custom URI scheme.
@@ -161,9 +164,22 @@ func (h *DumbSchemeHandler) registerDefaults() {
161164 }
162165 }))
163166
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+
164180 // API: Get current config (used by dumb://config)
165181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
166- if req.Method != "GET" {
182+ if req.Method != "" && req.Method != httpGET {
167183 return nil
168184 }
169185
@@ -172,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
172188
173189 // API: Get default config (used by Reset Defaults in dumb://config)
174190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
175- if req.Method != "GET" {
191+ if req.Method != "" && req.Method != httpGET {
176192 return nil
177193 }
178194
@@ -180,6 +196,145 @@ func (h *DumbSchemeHandler) registerDefaults() {
180196 }))
181197 }
182198
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+
183338 func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
184339 // Get hardware info for display and profile resolution
185340 // 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&amp;b=2"`)
20+ assert.Contains(t, body, "https://example.com/foo?a=1&amp;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, "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;")
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 (
44 "context"
55 "fmt"
66 "net/url"
7+ "os"
78 "strings"
89 "sync"
910 "sync/atomic"
@@ -135,39 +136,59 @@ type WebView struct {
135136 lastProgressUpdate atomic.Int64 // Unix nanoseconds
136137
137138 // Signal handler IDs for disconnection
138- signalIDs []uint32
139+ signalIDs []uintptr
139140
140141 // 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)
154156
155157 logger zerolog.Logger
156158 mu sync.RWMutex
157159
158160 frontendAttached atomic.Bool
161+ navigationActive atomic.Bool
159162
160163 // asyncCallbacks keeps references to async JS callbacks to prevent GC
161164 asyncCallbacks []interface{}
162165
166+ // runJSErrorStats aggregates repeated non-fatal RunJavaScript errors by domain+signature.
167+ runJSErrorStats map[string]runJSErrorStat
168+
163169 // findController is cached to prevent GC from collecting the Go wrapper
164170 findController *findControllerAdapter
165171 findControllerOnce sync.Once
166172
167173 backForwardList *webkit.BackForwardList
168- backForwardListSignalID uint32
174+ backForwardListSignalID uintptr
175+}
176+
177+type runJSErrorStat struct {
178+ count uint64
179+ lastLog time.Time
169180 }
170181
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+
171192 type findControllerAdapter struct {
172193 fc *webkit.FindController
173194 }
@@ -234,7 +255,7 @@ func (a *findControllerAdapter) GetSearchText() string {
234255 return a.fc.GetSearchText()
235256 }
236257
237-func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 {
258+func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint {
238259 if a == nil || a.fc == nil || callback == nil {
239260 return 0
240261 }
@@ -244,7 +265,7 @@ func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint
244265 return a.fc.ConnectFoundText(&cb)
245266 }
246267
247-func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
268+func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint {
248269 if a == nil || a.fc == nil || callback == nil {
249270 return 0
250271 }
@@ -254,7 +275,7 @@ func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
254275 return a.fc.ConnectFailedToFindText(&cb)
255276 }
256277
257-func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
278+func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint {
258279 if a == nil || a.fc == nil || callback == nil {
259280 return 0
260281 }
@@ -264,7 +285,7 @@ func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint))
264285 return a.fc.ConnectCountedMatches(&cb)
265286 }
266287
267-func (a *findControllerAdapter) DisconnectSignal(id uint32) {
288+func (a *findControllerAdapter) DisconnectSignal(id uint) {
268289 if a == nil || a.fc == nil || id == 0 {
269290 return
270291 }
@@ -300,10 +321,11 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
300321 }
301322
302323 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),
307329 }
308330
309331 // Register in global registry
@@ -349,11 +371,12 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
349371 Msg("related webview created, checking pointers")
350372
351373 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),
357380 }
358381
359382 wv.id = globalRegistry.register(wv)
@@ -388,6 +411,7 @@ func (wv *WebView) connectSignals() {
388411 wv.connectAudioStateSignal()
389412 wv.connectMouseTargetChangedSignal()
390413 wv.connectBackForwardListChangedSignal()
414+ wv.connectWebProcessTerminatedSignal()
391415 }
392416
393417 func (wv *WebView) connectLoadChangedSignal() {
@@ -404,6 +428,7 @@ func (wv *WebView) connectLoadChangedSignal() {
404428
405429 switch event {
406430 case webkit.LoadStartedValue:
431+ wv.navigationActive.Store(true)
407432 wv.isLoading = true
408433 wv.logger.Debug().Str("uri", uri).Msg("load started")
409434 case webkit.LoadRedirectedValue:
@@ -421,7 +446,7 @@ func (wv *WebView) connectLoadChangedSignal() {
421446 }
422447 }
423448 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
424- wv.signalIDs = append(wv.signalIDs, sigID)
449+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
425450 }
426451
427452 func (wv *WebView) connectCloseSignal() {
@@ -431,7 +456,7 @@ func (wv *WebView) connectCloseSignal() {
431456 }
432457 }
433458 sigID := wv.inner.ConnectClose(&closeCb)
434- wv.signalIDs = append(wv.signalIDs, sigID)
459+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
435460 }
436461
437462 func (wv *WebView) connectCreateSignal() {
@@ -473,7 +498,7 @@ func (wv *WebView) connectCreateSignal() {
473498 return newWV.inner.Widget
474499 }
475500 sigID := wv.inner.ConnectCreate(&createCb)
476- wv.signalIDs = append(wv.signalIDs, sigID)
501+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
477502 }
478503
479504 func (wv *WebView) connectReadyToShowSignal() {
@@ -483,7 +508,7 @@ func (wv *WebView) connectReadyToShowSignal() {
483508 }
484509 }
485510 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
486- wv.signalIDs = append(wv.signalIDs, sigID)
511+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
487512 }
488513
489514 func (wv *WebView) connectTitleSignal() {
@@ -498,7 +523,7 @@ func (wv *WebView) connectTitleSignal() {
498523 }
499524 }
500525 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))
502527 }
503528
504529 func (wv *WebView) connectURISignal() {
@@ -514,7 +539,7 @@ func (wv *WebView) connectURISignal() {
514539 }
515540 }
516541 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))
518543 }
519544
520545 func (wv *WebView) connectFaviconSignal() {
@@ -525,7 +550,7 @@ func (wv *WebView) connectFaviconSignal() {
525550 }
526551 }
527552 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))
529554 }
530555
531556 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
@@ -555,7 +580,7 @@ func (wv *WebView) connectProgressSignal() {
555580 }
556581 }
557582 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))
559584 }
560585
561586 func (wv *WebView) connectDecidePolicySignal() {
@@ -571,7 +596,7 @@ func (wv *WebView) connectDecidePolicySignal() {
571596 }
572597 }
573598 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
574- wv.signalIDs = append(wv.signalIDs, sigID)
599+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
575600 }
576601
577602 // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
@@ -770,7 +795,7 @@ func (wv *WebView) connectEnterFullscreenSignal() {
770795 return false // Allow fullscreen
771796 }
772797 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
773- wv.signalIDs = append(wv.signalIDs, sigID)
798+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
774799 }
775800
776801 func (wv *WebView) connectLeaveFullscreenSignal() {
@@ -783,7 +808,7 @@ func (wv *WebView) connectLeaveFullscreenSignal() {
783808 return false // Allow leaving fullscreen
784809 }
785810 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
786- wv.signalIDs = append(wv.signalIDs, sigID)
811+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
787812 }
788813
789814 func (wv *WebView) connectAudioStateSignal() {
@@ -801,7 +826,7 @@ func (wv *WebView) connectAudioStateSignal() {
801826 }
802827 }
803828 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))
805830 }
806831
807832 func (wv *WebView) connectMouseTargetChangedSignal() {
@@ -829,7 +854,7 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
829854 wv.OnLinkHover(uri)
830855 }
831856 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
832- wv.signalIDs = append(wv.signalIDs, sigID)
857+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
833858 }
834859
835860 func (wv *WebView) connectBackForwardListChangedSignal() {
@@ -857,7 +882,52 @@ func (wv *WebView) connectBackForwardListChangedSignal() {
857882 }
858883 sigID := backForwardList.ConnectChanged(&changedCb)
859884 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))
861931 }
862932
863933 // ID returns the unique identifier for this WebView.
@@ -907,6 +977,7 @@ func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
907977 if wv.destroyed.Load() {
908978 return fmt.Errorf("webview %d is destroyed", wv.id)
909979 }
980+ wv.navigationActive.Store(true)
910981 wv.inner.LoadUri(uri)
911982 logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")
912983 return nil
@@ -917,6 +988,7 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error
917988 if wv.destroyed.Load() {
918989 return fmt.Errorf("webview %d is destroyed", wv.id)
919990 }
991+ wv.navigationActive.Store(true)
920992 var baseURIPtr *string
921993 if baseURI != "" {
922994 baseURIPtr = &baseURI
@@ -1140,6 +1212,8 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
11401212 wv.OnFaviconChanged = nil
11411213 wv.OnClose = nil
11421214 wv.OnCreate = nil
1215+ wv.OnLinkHover = nil
1216+ wv.OnWebProcessTerminated = nil
11431217 return
11441218 }
11451219
@@ -1179,6 +1253,13 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
11791253 }
11801254 }
11811255 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+ }
11821263 }
11831264
11841265 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1215,6 +1296,16 @@ func (wv *WebView) IsDestroyed() bool {
12151296 return wv.destroyed.Load()
12161297 }
12171298
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+
12181309 // Close triggers the close callback as if window.close() was called.
12191310 // This is used for programmatic popup closure (e.g., OAuth auto-close).
12201311 func (wv *WebView) Close() {
@@ -1236,13 +1327,13 @@ func (wv *WebView) DisconnectSignals() {
12361327
12371328 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
12381329 for _, sigID := range wv.signalIDs {
1239- gobject.SignalHandlerDisconnect(obj, sigID)
1330+ gobject.SignalHandlerDisconnect(obj, uint(sigID))
12401331 }
12411332 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
12421333
12431334 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
12441335 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1245- gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)
1336+ gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
12461337 wv.backForwardListSignalID = 0
12471338 wv.backForwardList = nil
12481339 }
@@ -1254,9 +1345,16 @@ func (wv *WebView) DisconnectSignals() {
12541345 // This must be called when a WebView is no longer needed to free GPU resources,
12551346 // VA-API decoder contexts, and DMA-BUF buffers held by the web process.
12561347 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) {
12571354 if wv.destroyed.Swap(true) {
12581355 return // Already destroyed
12591356 }
1357+ policy = resolveTerminatePolicy(policy)
12601358
12611359 // 1. Disconnect all signal handlers first to prevent callbacks during cleanup
12621360 wv.DisconnectSignals()
@@ -1275,6 +1373,7 @@ func (wv *WebView) Destroy() {
12751373 wv.OnLeaveFullscreen = nil
12761374 wv.OnAudioStateChanged = nil
12771375 wv.OnLinkHover = nil
1376+ wv.OnWebProcessTerminated = nil
12781377
12791378 // 3. Clear async callback references
12801379 wv.mu.Lock()
@@ -1287,11 +1386,9 @@ func (wv *WebView) Destroy() {
12871386 wv.inner.Unparent()
12881387 }
12891388
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 {
12951392 wv.inner.TerminateWebProcess()
12961393 }
12971394
@@ -1303,13 +1400,176 @@ func (wv *WebView) Destroy() {
13031400 wv.ucm = nil
13041401 wv.findController = nil
13051402
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
13101469 }
13111470 }
13121471
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+
13131573 // RunJavaScript executes script in the specified world (empty for main world).
13141574 // This is fire-and-forget: it does not block and errors are logged asynchronously.
13151575 // Safe to call from any context including GTK signal handlers.
@@ -1324,25 +1584,68 @@ func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string)
13241584 log := logging.FromContext(ctx)
13251585
13261586 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()
13271598 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+ }
13291609 return
13301610 }
13311611
13321612 res := &gio.AsyncResultBase{Ptr: resPtr}
1333- value, err := wv.inner.EvaluateJavascriptFinish(res)
1613+ value, err := inner.EvaluateJavascriptFinish(res)
13341614 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+ }
13361631 return
13371632 }
13381633
13391634 if value != nil {
13401635 if jscCtx := value.GetContext(); jscCtx != nil {
13411636 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+ }
13461649 }
13471650 }
13481651 }
@@ -1359,7 +1662,13 @@ func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string)
13591662 if worldName != "" {
13601663 worldNamePtr = &worldName
13611664 }
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)
13631672 }
13641673
13651674 // 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 (
1212 "github.com/jwijenbergh/puregotk/v4/gdk"
1313 "github.com/jwijenbergh/puregotk/v4/gio"
1414 "github.com/jwijenbergh/puregotk/v4/glib"
15+ "github.com/rs/zerolog"
1516 )
1617
1718 // FaviconAdapter bridges the domain FaviconService to GTK by providing
@@ -21,8 +22,12 @@ type FaviconAdapter struct {
2122 faviconDB *webkit.FaviconDatabase
2223 textureCache map[string]*gdk.Texture
2324 mu sync.RWMutex
25+ warnMu sync.Mutex
26+ warnCounts map[string]int
2427 }
2528
29+type warningLogFunc func(log *zerolog.Logger, err error)
30+
2631 // NewFaviconAdapter creates a new FaviconAdapter.
2732 // The faviconDB can be nil if WebKit favicon database is not available.
2833 func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
@@ -30,6 +35,7 @@ func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDataba
3035 service: service,
3136 faviconDB: faviconDB,
3237 textureCache: make(map[string]*gdk.Texture),
38+ warnCounts: make(map[string]int),
3339 }
3440 }
3541
@@ -195,6 +201,9 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
195201 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
196202 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
197203 log := logging.FromContext(ctx)
204+ cacheDirWarnKey := "cache-dir"
205+ savePNGWarnKey := "save-png:" + domain
206+ sizedPNGWarnKey := "sized-png:" + domain
198207
199208 // Check if PNG save is needed before scheduling idle callback
200209 pngPath := a.service.DiskPathPNG(domain)
@@ -203,8 +212,12 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
203212 if needsPNGSave {
204213 // Ensure cache directory exists (cheap check, do it now)
205214 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+ })
207218 needsPNGSave = false
219+ } else {
220+ a.clearWarningDedup(cacheDirWarnKey)
208221 }
209222 }
210223
@@ -213,15 +226,21 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
213226 // texture.SaveToPng must run on main thread but we defer it to avoid blocking
214227 cb := glib.SourceFunc(func(_ uintptr) bool {
215228 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+ })
217232 } else {
233+ a.clearWarningDedup(savePNGWarnKey)
218234 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
219235
220236 // Create normalized sized copy for dmenu/fuzzel (async)
221237 go func() {
222238 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+ })
224242 } else {
243+ a.clearWarningDedup(sizedPNGWarnKey)
225244 log.Debug().Str("domain", domain).Msg("created sized PNG")
226245 }
227246 }()
@@ -318,13 +337,54 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
318337 if hasPNG && !hasSized {
319338 log.Debug().Str("domain", domain).Msg("creating sized PNG")
320339 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+ })
322343 } else {
344+ a.clearWarningDedup("sized-png:" + domain)
323345 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
324346 }
325347 }
326348 }
327349
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+
328388 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
329389 // The texture is cached under the InternalDomain key.
330390 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) {
13931393 a.deps.NavigateUC,
13941394 a.contentCoord,
13951395 )
1396+ a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
1397+ a.navCoord.ClearPaneHistory(paneID)
1398+ })
13961399
13971400 // Wire title updates to history persistence
13981401 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 @@
11 package component
22
33 import (
4+ "sync/atomic"
5+
46 "github.com/bnema/dumber/internal/ui/layout"
57 "github.com/jwijenbergh/puregotk/v4/gtk"
68 )
79
810 // LoadingSkeleton displays a themed loading placeholder.
911 // 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.
1113 type LoadingSkeleton struct {
1214 container layout.BoxWidget
1315 content layout.BoxWidget
1416 spinner layout.SpinnerWidget
1517 logo layout.ImageWidget
18+ version layout.LabelWidget
1619 }
1720
1821 const (
19- loadingSkeletonSpacing = 6
20- loadingSkeletonLogoSize = 512
22+ loadingSkeletonSpacing = 6
23+ loadingSkeletonLogoSize = 256
24+ loadingSkeletonSpinnerSize = 32
25+ loadingSkeletonVersionMaxChars = 16
2126 )
2227
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+
2336 func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
2437 container := factory.NewBox(layout.OrientationVertical, 0)
2538 container.SetHexpand(true)
@@ -37,14 +50,6 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
3750 content.SetCanTarget(false)
3851 content.AddCssClass("loading-skeleton-content")
3952
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-
4853 // Faded app logo
4954 logo := factory.NewImage()
5055 logo.SetHalign(gtk.AlignCenterValue)
@@ -60,9 +65,33 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
6065 logo.SetFromPaintable(logoTexture)
6166 }
6267
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)
6492 content.Append(logo)
6593 content.Append(spinner)
94+ content.Append(versionLabel)
6695 container.Append(content)
6796
6897 ls := &LoadingSkeleton{
@@ -70,6 +99,7 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
7099 content: content,
71100 spinner: spinner,
72101 logo: logo,
102+ version: versionLabel,
73103 }
74104 ls.SetVisible(true)
75105 return ls
M internal/ui/component/loading_skeleton_test.go +42 -20
@@ -17,6 +17,7 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
1717 mockContent := mocks.NewMockBoxWidget(t)
1818 mockSpinner := mocks.NewMockSpinnerWidget(t)
1919 mockLogo := mocks.NewMockImageWidget(t)
20+ mockVersion := mocks.NewMockLabelWidget(t)
2021
2122 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
2223 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -34,26 +35,36 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
3435 mockContent.EXPECT().SetCanTarget(false).Once()
3536 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
3637
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-
4538 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
4639 mockLogo.EXPECT().SetHalign(mock.Anything).Once()
4740 mockLogo.EXPECT().SetValign(mock.Anything).Once()
4841 mockLogo.EXPECT().SetCanFocus(false).Once()
4942 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()
5245 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
5346 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
5447
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+
5565 mockContent.EXPECT().Append(mockLogo).Once()
5666 mockContent.EXPECT().Append(mockSpinner).Once()
67+ mockContent.EXPECT().Append(mockVersion).Once()
5768 mockContainer.EXPECT().Append(mockContent).Once()
5869
5970 // NewLoadingSkeleton ends by calling SetVisible(true).
@@ -70,6 +81,7 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
7081 mockContent := mocks.NewMockBoxWidget(t)
7182 mockSpinner := mocks.NewMockSpinnerWidget(t)
7283 mockLogo := mocks.NewMockImageWidget(t)
84+ mockVersion := mocks.NewMockLabelWidget(t)
7385
7486 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
7587 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -87,26 +99,36 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
8799 mockContent.EXPECT().SetCanTarget(false).Once()
88100 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
89101
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-
98102 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
99103 mockLogo.EXPECT().SetHalign(mock.Anything).Once()
100104 mockLogo.EXPECT().SetValign(mock.Anything).Once()
101105 mockLogo.EXPECT().SetCanFocus(false).Once()
102106 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()
105109 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
106110 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
107111
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+
108129 mockContent.EXPECT().Append(mockLogo).Once()
109130 mockContent.EXPECT().Append(mockSpinner).Once()
131+ mockContent.EXPECT().Append(mockVersion).Once()
110132 mockContainer.EXPECT().Append(mockContent).Once()
111133
112134 mockContainer.EXPECT().SetVisible(true).Once()
M internal/ui/component/omnibox.go +220 -209
@@ -3,7 +3,7 @@ package component
33 import (
44 "context"
55 "fmt"
6- "math"
6+ stdurl "net/url"
77 "strings"
88 "sync"
99 "time"
@@ -21,15 +21,12 @@ import (
2121 "github.com/jwijenbergh/puregotk/v4/glib"
2222 "github.com/jwijenbergh/puregotk/v4/graphene"
2323 "github.com/jwijenbergh/puregotk/v4/gtk"
24- "github.com/jwijenbergh/puregotk/v4/pango"
2524 )
2625
2726 const (
2827 debounceDelayMs = 50
2928 endBoxSpacing = 6
3029 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31- ghostPositionRetryMs = 16
32- ghostPositionMaxAttempts = 5
3330 minGhostInputLength = 1
3431 )
3532
@@ -76,8 +73,6 @@ type Omnibox struct {
7673 zoomLabel *gtk.Label
7774 bangBadge *gtk.Label
7875 entry *gtk.SearchEntry
79- entryOverlay *gtk.Overlay // Overlay for ghost text
80- ghostLabel *gtk.Label // Ghost text completion label
8176 scrolledWin *gtk.ScrolledWindow
8277 listBox *gtk.ListBox
8378
@@ -123,6 +118,8 @@ type Omnibox struct {
123118 debounceTimer *time.Timer
124119 debounceMu sync.Mutex
125120 lastQuery string // Prevent duplicate searches
121+ searchToken uint64
122+ ghostToken uint64
126123
127124 // Scaling
128125 uiScale float64
@@ -512,30 +509,6 @@ func (o *Omnibox) initEntry() error {
512509 placeholder := defaultOmniboxPlaceholder
513510 o.entry.SetPlaceholderText(&placeholder)
514511
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-
539512 return nil
540513 }
541514
@@ -636,7 +609,7 @@ func (o *Omnibox) assembleWidgets() {
636609
637610 o.scrolledWin.SetChild(&o.listBox.Widget)
638611 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)
640613 o.mainBox.Append(&o.scrolledWin.Widget)
641614 o.outerBox.Append(&o.mainBox.Widget)
642615 }
@@ -825,6 +798,10 @@ func (o *Omnibox) onEntryChanged() {
825798 return
826799 }
827800
801+ if o.isGhostProgrammaticEcho(entryText) {
802+ return
803+ }
804+
828805 // Reset navigation state when user types - space should type, not toggle favorite
829806 o.mu.Lock()
830807 o.hasNavigated = false
@@ -850,169 +827,129 @@ func (o *Omnibox) onEntryChanged() {
850827 o.debounceMu.Unlock()
851828 }
852829
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.
857861 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
858- if o.ghostLabel == nil || suffix == "" {
862+ if o.entry == nil || suffix == "" {
859863 o.clearGhostText()
860864 return
861865 }
862866
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+
864874 o.mu.Lock()
875+ o.realInput = originalInput
865876 o.ghostSuffix = suffix
866877 o.ghostFullText = fullText
867878 o.hasGhostText = true
879+ capturedToken := o.ghostToken
868880 o.mu.Unlock()
869881
870- o.scheduleGhostPosition(originalInput, suffix, 0)
871-}
872-
873-func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
874882 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 {
879884 return false
880885 }
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 {
883892 return false
884893 }
885-
886- marginStart, ok := o.computeGhostMarginStart(originalInput)
887- if !ok {
894+ if capturedToken != currentToken {
888895 return false
889896 }
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()
925900
926- return false
927-}
901+ o.entry.SetText(displayText)
902+ o.entry.SetPosition(selectionStart)
903+ o.entry.SelectRegion(selectionStart, selectionEnd)
928904
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()
932908 return false
933909 }
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)
992911 }
993912
994913 // clearGhostText hides the ghost completion text.
995914 func (o *Omnibox) clearGhostText() {
996915 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
997921 o.ghostSuffix = ""
998922 o.ghostFullText = ""
999923 o.hasGhostText = false
1000924 o.mu.Unlock()
1001925
1002- if o.ghostLabel == nil {
926+ if !hadGhost || o.entry == nil {
1003927 return
1004928 }
1005929
1006930 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
1010933 }
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
1015939 }
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()
1016953 return false
1017954 }
1018955 glib.IdleAdd(&cb, 0)
@@ -1021,54 +958,42 @@ func (o *Omnibox) clearGhostText() {
1021958 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
1022959 // This prevents stale background results from wiping newer ghost text.
1023960 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 {
1025965 return
1026966 }
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()
1049968 }
1050969
1051970 // acceptGhostCompletion accepts the ghost text and fills the input.
1052971 func (o *Omnibox) acceptGhostCompletion() {
1053972 o.mu.Lock()
1054- if !o.hasGhostText || o.ghostFullText == "" {
973+ fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)
974+ if !accepted {
1055975 o.mu.Unlock()
1056976 return
1057977 }
1058- fullText := o.ghostFullText
1059978 o.isAcceptingGhost = true
1060979 o.realInput = fullText
980+ o.selectedIndex = -1
981+ o.hasNavigated = false
982+ o.ghostToken++
1061983 o.ghostSuffix = ""
1062984 o.ghostFullText = ""
1063985 o.hasGhostText = false
1064986 o.mu.Unlock()
1065987
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)
1072997 }
1073998
1074999 o.mu.Lock()
@@ -1076,6 +1001,13 @@ func (o *Omnibox) acceptGhostCompletion() {
10761001 o.mu.Unlock()
10771002 }
10781003
1004+func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) {
1005+ if !hasGhost || ghostFullText == "" {
1006+ return "", false
1007+ }
1008+ return ghostFullText, true
1009+}
1010+
10791011 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
10801012 // When a row is selected via arrow keys, show the URL as ghost text even if
10811013 // 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
11791111 }
11801112 }
11811113
1114+ o.mu.Lock()
1115+ o.ghostToken++
1116+ ghostToken := o.ghostToken
1117+ searchToken := o.searchToken
1118+ o.mu.Unlock()
1119+
11821120 go func() {
11831121 suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
11841122 VisibleURLs: visibleURLs,
11851123 AllowBangs: false,
11861124 })
1125+ if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) {
1126+ return
1127+ }
11871128 if suggestion == nil {
11881129 o.clearGhostTextIfInput(entryText)
11891130 return
11901131 }
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)
11921138 }()
11931139 }
11941140
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+
11951191 // performSearch executes the search based on current view mode and query.
11961192 func (o *Omnibox) performSearch() {
11971193 o.mu.RLock()
@@ -1215,6 +1211,11 @@ func (o *Omnibox) performSearch() {
12151211 o.lastQuery = query
12161212 o.debounceMu.Unlock()
12171213
1214+ o.mu.Lock()
1215+ o.searchToken++
1216+ token := o.searchToken
1217+ o.mu.Unlock()
1218+
12181219 if strings.HasPrefix(query, "!") {
12191220 o.updateBangDetection(query)
12201221 o.loadBangSuggestions(query)
@@ -1224,23 +1225,23 @@ func (o *Omnibox) performSearch() {
12241225 o.clearBangState()
12251226
12261227 if mode == ViewModeFavorites {
1227- o.loadFavorites(query)
1228+ o.loadFavorites(query, token)
12281229 return
12291230 }
12301231
12311232 // History search
12321233 if query == "" {
1233- o.loadInitialHistory()
1234+ o.loadInitialHistory(token)
12341235 return
12351236 }
12361237
12371238 // Perform fuzzy history search in background
1238- o.searchHistory(query, o.effectiveMaxRows())
1239+ o.searchHistory(query, o.effectiveMaxRows(), token)
12391240 }
12401241
12411242 // searchHistory runs a fuzzy history search in a background goroutine.
12421243 // 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) {
12441245 go func() {
12451246 ctx := o.ctx
12461247 log := logging.FromContext(ctx)
@@ -1291,13 +1292,13 @@ func (o *Omnibox) searchHistory(query string, limit int) {
12911292 })
12921293 }
12931294
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)
12961297 }()
12971298 }
12981299
12991300 // loadInitialHistory loads history based on InitialBehavior config.
1300-func (o *Omnibox) loadInitialHistory() {
1301+func (o *Omnibox) loadInitialHistory(token uint64) {
13011302 if o.historyUC == nil {
13021303 return
13031304 }
@@ -1312,7 +1313,7 @@ func (o *Omnibox) loadInitialHistory() {
13121313
13131314 switch o.initialBehavior {
13141315 case "none":
1315- o.idleAddUpdateSuggestions(nil, "")
1316+ o.idleAddUpdateSuggestions(nil, "", token)
13161317 return
13171318
13181319 case "most_visited", "recent", "":
@@ -1357,12 +1358,12 @@ func (o *Omnibox) loadInitialHistory() {
13571358 }
13581359 }
13591360
1360- o.idleAddUpdateSuggestions(suggestions, "")
1361+ o.idleAddUpdateSuggestions(suggestions, "", token)
13611362 }()
13621363 }
13631364
13641365 // loadFavorites loads favorites, optionally filtered by query.
1365-func (o *Omnibox) loadFavorites(query string) {
1366+func (o *Omnibox) loadFavorites(query string, token uint64) {
13661367 if o.favoritesUC == nil {
13671368 return
13681369 }
@@ -1395,7 +1396,7 @@ func (o *Omnibox) loadFavorites(query string) {
13951396 })
13961397 }
13971398
1398- o.idleAddUpdateFavorites(favorites, query)
1399+ o.idleAddUpdateFavorites(favorites, query, token)
13991400 }()
14001401 }
14011402
@@ -1976,6 +1977,7 @@ func (o *Omnibox) toggleFavorite() {
19761977 log.Debug().Msg("remove favorite: invalid ID")
19771978 return
19781979 }
1980+ entryText := o.entry.GetText()
19791981
19801982 go func() {
19811983 ctx := o.ctx
@@ -2000,7 +2002,10 @@ func (o *Omnibox) toggleFavorite() {
20002002 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
20012003
20022004 // 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)
20042009 if o.onToast != nil {
20052010 cb := glib.SourceFunc(func(_ uintptr) bool {
20062011 o.onToast(ctx, "Favorite removed", ToastSuccess)
@@ -2139,13 +2144,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
21392144 o.isAcceptingGhost = false
21402145 o.mu.Unlock()
21412146
2142- // Clear any stale ghost text display
2143- if o.ghostLabel != nil {
2144- o.ghostLabel.SetVisible(false)
2145- }
2146-
21472147 // Set initial query
21482148 o.entry.SetText(query)
2149+ o.entry.SelectRegion(-1, -1)
21492150
21502151 // Determine if we expect content initially
21512152 // No content expected if: no query AND initialBehavior is "none"
@@ -2268,9 +2269,11 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
22682269 }
22692270
22702271 // 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) {
22732273 var cb glib.SourceFunc = func(data uintptr) bool {
2274+ if !o.isSearchTokenCurrent(token) {
2275+ return false
2276+ }
22742277 o.updateSuggestions(suggestions, query)
22752278 return false // One-shot callback
22762279 }
@@ -2293,15 +2296,23 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
22932296 }
22942297
22952298 // 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) {
22982300 var cb glib.SourceFunc = func(data uintptr) bool {
2301+ if !o.isSearchTokenCurrent(token) {
2302+ return false
2303+ }
22992304 o.updateFavorites(favorites, query)
23002305 return false // One-shot callback, return false to remove source
23012306 }
23022307 glib.IdleAdd(&cb, 0)
23032308 }
23042309
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+
23052316 // Widget returns the omnibox widget for embedding in an overlay.
23062317 func (o *Omnibox) Widget() *gtk.Widget {
23072318 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 {
2929 paneID entity.PaneID
3030 isActive bool
3131
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()
3536
3637 hoverHandler *input.HoverHandler
3738
@@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
218219 pv.onHover = fn
219220 }
220221
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+
221231 // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
222232 func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
223233 pv.mu.Lock()
@@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
226236 // Create hover handler
227237 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
228238
229- // Wire up callback
239+ // Wire up hover enter callback
230240 pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
231241 pv.mu.RLock()
232242 callback := pv.onHover
@@ -237,6 +247,17 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
237247 }
238248 })
239249
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+
240261 // Attach to overlay widget
241262 gtkWidget := pv.overlay.GtkWidget()
242263 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) {
1515 mockContent := mocks.NewMockBoxWidget(t)
1616 mockSpinner := mocks.NewMockSpinnerWidget(t)
1717 mockLogo := mocks.NewMockImageWidget(t)
18+ mockVersion := mocks.NewMockLabelWidget(t)
1819
1920 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
2021 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -32,26 +33,36 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
3233 mockContent.EXPECT().SetCanTarget(false).Once()
3334 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
3435
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-
4336 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
4437 mockLogo.EXPECT().SetHalign(mock.Anything).Once()
4538 mockLogo.EXPECT().SetValign(mock.Anything).Once()
4639 mockLogo.EXPECT().SetCanFocus(false).Once()
4740 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()
5043 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
5144 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
5245
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+
5363 mockContent.EXPECT().Append(mockLogo).Once()
5464 mockContent.EXPECT().Append(mockSpinner).Once()
65+ mockContent.EXPECT().Append(mockVersion).Once()
5566 mockContainer.EXPECT().Append(mockContent).Once()
5667
5768 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) {
3434 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
3535 mockOverlay.EXPECT().SetChild(mockWebView).Once()
3636
37- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
37+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
3838
3939 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
4040 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -75,7 +75,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
7575 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
7676 // SetChild should NOT be called when webview is nil
7777
78- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
78+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
7979
8080 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
8181 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -509,6 +509,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
509509
510510 // Helper function to setup common mock expectations for PaneView creation
511511 func setupLoadingSkeletonMocks(
512+ t *testing.T,
512513 mockFactory *mocks.MockWidgetFactory,
513514 mockOverlay *mocks.MockOverlayWidget,
514515 mockLoadingContainer *mocks.MockBoxWidget,
@@ -516,6 +517,8 @@ func setupLoadingSkeletonMocks(
516517 mockLoadingSpinner *mocks.MockSpinnerWidget,
517518 mockLoadingLogo *mocks.MockImageWidget,
518519 ) {
520+ mockLoadingVersion := mocks.NewMockLabelWidget(t)
521+
519522 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
520523 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
521524 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
@@ -533,6 +536,16 @@ func setupLoadingSkeletonMocks(
533536 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
534537 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
535538
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+
536549 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
537550 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
538551 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
@@ -542,18 +555,18 @@ func setupLoadingSkeletonMocks(
542555 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
543556 mockLoadingSpinner.EXPECT().Start().Maybe()
544557
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()
554566
555567 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
556568 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
569+ mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe()
557570 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
558571
559572 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -581,7 +594,7 @@ func setupPaneViewMocks(
581594 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
582595 mockOverlay.EXPECT().SetChild(mockWebView).Once()
583596
584- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
597+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
585598
586599 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
587600 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -611,7 +624,7 @@ func setupPaneViewMocksNoWebView(
611624 mockOverlay.EXPECT().SetVisible(true).Once()
612625 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
613626
614- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
627+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
615628
616629 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
617630 mockBorderBox.EXPECT().SetCanFocus(false).Once()
M internal/ui/component/workspace_view.go +26 -1
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "sync"
9+ "sync/atomic"
910 "time"
1011
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -56,6 +57,14 @@ type WorkspaceView struct {
5657 // Prevents hover focus from overriding keyboard-initiated focus changes
5758 hoverSuppressedUntil time.Time
5859
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+
5968 // Auto-open omnibox on new pane creation
6069 autoOpenOnNewPane bool
6170
@@ -93,6 +102,13 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
93102 }
94103 })
95104
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+
96112 // Set up hover callback for focus-follows-mouse
97113 pv.SetOnHover(func(paneID entity.PaneID) {
98114 // Skip if hover is suppressed (keyboard navigation just happened)
@@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
100116 return
101117 }
102118
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+
103125 // Skip if this pane is already active
104126 if a.wv.GetActivePaneID() == paneID {
105127 return
@@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
566588 // NotifyNewPaneCreated handles new pane creation events.
567589 // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
568590 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)
570595 wv.SuppressHover(KeyboardFocusSuppressDuration)
571596 wv.CancelAllPendingHovers()
572597
M internal/ui/component/workspace_view_test.go +27 -14
@@ -15,6 +15,7 @@ import (
1515 )
1616
1717 func setupLoadingSkeletonMocksWorkspace(
18+ t *testing.T,
1819 mockFactory *mocks.MockWidgetFactory,
1920 mockOverlay *mocks.MockOverlayWidget,
2021 mockLoadingContainer *mocks.MockBoxWidget,
@@ -22,6 +23,8 @@ func setupLoadingSkeletonMocksWorkspace(
2223 mockLoadingSpinner *mocks.MockSpinnerWidget,
2324 mockLoadingLogo *mocks.MockImageWidget,
2425 ) {
26+ mockLoadingVersion := mocks.NewMockLabelWidget(t)
27+
2528 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
2629 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
2730 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
@@ -39,27 +42,37 @@ func setupLoadingSkeletonMocksWorkspace(
3942 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
4043 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
4144
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+
4255 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
4356 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
4457 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
4558 mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
4659 mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
47- mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
60+ mockLoadingSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
4861 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
4962 mockLoadingSpinner.EXPECT().Start().Maybe()
5063
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()
6072
6173 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
6274 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
75+ mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe()
6376 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
6477
6578 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -101,7 +114,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
101114 mockOverlay.EXPECT().SetVisible(true).Once()
102115 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
103116
104- setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
117+ setupLoadingSkeletonMocksWorkspace(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
105118
106119 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
107120 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -166,7 +179,7 @@ func setupStackedLeafMocks(
166179 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
167180
168181 // Close button click handler
169- mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
182+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
170183
171184 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
172185 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
@@ -274,9 +287,9 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
274287 mockStackBox2.EXPECT().SetVisible(true).Once()
275288 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
276289 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()
278291 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()
280293 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
281294
282295 mockBox.EXPECT().Append(mockPaned).Once()
M internal/ui/coordinator/content.go +353 -103
@@ -4,6 +4,7 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "net/url"
78 "strings"
89 "sync"
910 "time"
@@ -20,12 +21,16 @@ import (
2021 "github.com/bnema/dumber/internal/ui/component"
2122 "github.com/bnema/dumber/internal/ui/input"
2223 "github.com/bnema/dumber/internal/ui/layout"
24+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
2325 "github.com/jwijenbergh/puregotk/v4/gdk"
26+ "github.com/jwijenbergh/puregotk/v4/glib"
2427 )
2528
2629 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
2934
3035 // Dark theme background color (#0a0a0b) as float32 RGBA values
3136 darkBgR = 0.039
@@ -34,6 +39,51 @@ const (
3439 darkBgA = 1.0
3540 )
3641
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+
3787 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
3888 type ContentCoordinator struct {
3989 pool *webkit.WebViewPool
@@ -43,6 +93,7 @@ type ContentCoordinator struct {
4393 injector *webkit.ContentInjector
4494
4595 webViews map[entity.PaneID]*webkit.WebView
96+ webViewsMu sync.RWMutex
4697 paneTitles map[entity.PaneID]string
4798 titleMu sync.RWMutex
4899
@@ -87,6 +138,8 @@ type ContentCoordinator struct {
87138 factory *webkit.WebViewFactory
88139 popupConfig *config.PopupBehaviorConfig
89140 pendingPopups map[port.WebViewID]*PendingPopup
141+ popupOAuth map[port.WebViewID]*popupOAuthState
142+ popupRefresh map[entity.PaneID]*time.Timer
90143 popupMu sync.RWMutex
91144
92145 // Callback to insert popup into workspace (avoids circular dependency)
@@ -117,6 +170,14 @@ type pendingThemeUpdate struct {
117170 cssText string
118171 }
119172
173+type popupOAuthState struct {
174+ ParentPaneID entity.PaneID
175+ CallbackURI string
176+ Success bool
177+ Error bool
178+ Seen bool
179+}
180+
120181 // NewContentCoordinator creates a new ContentCoordinator.
121182 func NewContentCoordinator(
122183 ctx context.Context,
@@ -140,6 +201,8 @@ func NewContentCoordinator(
140201 pendingReveal: make(map[entity.PaneID]bool),
141202 getActiveWS: getActiveWS,
142203 pendingPopups: make(map[port.WebViewID]*PendingPopup),
204+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
205+ popupRefresh: make(map[entity.PaneID]*time.Timer),
143206 }
144207 }
145208
@@ -199,7 +262,7 @@ func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
199262 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
200263 log := logging.FromContext(ctx)
201264
202- if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
265+ if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
203266 return wv, nil
204267 }
205268
@@ -208,7 +271,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
208271 }
209272
210273 // Mark tab_created on first webview (first tab)
211- if len(c.webViews) == 0 {
274+ if c.webViewCount() == 0 {
212275 logging.Trace().Mark("tab_created")
213276 }
214277
@@ -218,7 +281,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
218281 }
219282 logging.Trace().Mark("webview_acquired")
220283
221- c.webViews[paneID] = wv
284+ c.setWebViewLocked(paneID, wv)
222285 c.setupWebViewCallbacks(ctx, paneID, wv)
223286
224287 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
229292 func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
230293 log := logging.FromContext(ctx)
231294
232- wv, ok := c.webViews[paneID]
233- if !ok || wv == nil {
295+ wv := c.deleteWebViewLocked(paneID)
296+ if wv == nil {
234297 return
235298 }
236- delete(c.webViews, paneID)
237299 c.clearPendingAppearance(paneID)
238300
239301 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
@@ -360,28 +422,64 @@ func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView
360422 return nil
361423 }
362424
363- return c.webViews[pane.Pane.ID]
425+ return c.getWebViewLocked(pane.Pane.ID)
364426 }
365427
366428 // GetWebView returns the WebView for a specific pane.
367429 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
368- return c.webViews[paneID]
430+ return c.getWebViewLocked(paneID)
369431 }
370432
371433 // RegisterPopupWebView registers a popup WebView that was created externally.
372434 // This is used when popup tabs are created and the WebView needs to be tracked.
373435 func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
374436 if wv != nil && paneID != "" {
375- c.webViews[paneID] = wv
437+ c.setWebViewLocked(paneID, wv)
376438 }
377439 }
378440
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+
379477 // ApplyFiltersToAll applies content filters to all active webviews.
380478 // Called when filters become available after webviews were already created.
381479 func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
382480 log := logging.FromContext(ctx)
383481
384- for paneID, wv := range c.webViews {
482+ for paneID, wv := range c.snapshotWebViews() {
385483 if wv != nil && !wv.IsDestroyed() {
386484 applier.ApplyTo(ctx, wv.UserContentManager())
387485 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.
396494 return
397495 }
398496
399- for paneID, wv := range c.webViews {
497+ for paneID, wv := range c.snapshotWebViews() {
400498 if wv == nil || wv.IsDestroyed() {
401499 continue
402500 }
@@ -740,10 +838,10 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
740838
741839 // Notify history persistence (get URL from WebView)
742840 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)
747845 }
748846 }
749847 }
@@ -782,12 +880,28 @@ func (c *ContentCoordinator) updateStackedPaneTitle(
782880 }
783881 }
784882
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+
785899 // onFaviconChanged updates favicon tracking when a WebView's favicon changes.
786900 func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
787901 log := logging.FromContext(ctx)
788902
789903 // Get current URI to extract domain for caching
790- wv := c.webViews[paneID]
904+ wv := c.getWebViewLocked(paneID)
791905 if wv == nil {
792906 return
793907 }
@@ -851,27 +965,25 @@ func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
851965 // SetNavigationOrigin records the original URL before navigation starts.
852966 // This allows caching favicons under both original and final domains
853967 // 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) {
855969 c.navOriginMu.Lock()
856- c.navOrigins[paneID] = url
970+ c.navOrigins[paneID] = uri
857971 c.navOriginMu.Unlock()
858972 }
859973
860974 // PreloadCachedFavicon checks the favicon cache and updates the stacked pane
861975 // title bar immediately if a cached favicon exists for the URL.
862976 // 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 == "" {
865979 return
866980 }
867981
868982 // 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)
873984
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.
875987 _, wsView := c.getActiveWS()
876988 if wsView != nil {
877989 tr := wsView.TreeRenderer()
@@ -892,14 +1004,14 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
8921004 log := logging.FromContext(ctx)
8931005 logging.Trace().Mark("load_committed")
8941006
895- url := wv.URI()
896- if url == "" {
1007+ uri := wv.URI()
1008+ if uri == "" {
8971009 return
8981010 }
8991011
9001012 // Set appropriate background color based on page type to prevent dark background bleeding.
9011013 switch {
902- case strings.HasPrefix(url, "dumb://"):
1014+ case strings.HasPrefix(uri, "dumb://"):
9031015 // Internal pages: apply themed background
9041016 theme, ok := c.getCurrentTheme()
9051017 if ok && theme.prefersDark {
@@ -907,7 +1019,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
9071019 } else {
9081020 wv.ResetBackgroundToDefault()
9091021 }
910- case strings.HasPrefix(url, "about:"):
1022+ case strings.HasPrefix(uri, "about:"):
9111023 // Keep pool background (no action)
9121024 default:
9131025 // External pages: white background
@@ -919,7 +1031,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
9191031 // Skip showing if this is about:blank but the pane is loading a different URL
9201032 // This prevents the brief flash of about:blank during initial navigation
9211033 shouldShow := true
922- if url == aboutBlankURI {
1034+ if uri == aboutBlankURI {
9231035 // Get the pane's intended URI from the workspace
9241036 ws, _ := c.getActiveWS()
9251037 if ws != nil {
@@ -950,15 +1062,22 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
9501062
9511063 c.markPendingReveal(paneID)
9521064 if wv.EstimatedProgress() > 0 {
953- c.revealIfPending(ctx, paneID, url, "progress-after-commit")
1065+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
9541066 }
9551067
9561068 // 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+ }
9581077
9591078 // Record history - URI is guaranteed to be correct at LoadCommitted
9601079 if c.onHistoryRecord != nil {
961- c.onHistoryRecord(ctx, paneID, url)
1080+ c.onHistoryRecord(ctx, paneID, uri)
9621081 }
9631082
9641083 // Apply zoom
@@ -966,7 +1085,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
9661085 return
9671086 }
9681087
969- domain, err := usecase.ExtractDomain(url)
1088+ domain, err := usecase.ExtractDomain(uri)
9701089 if err != nil {
9711090 return
9721091 }
@@ -997,24 +1116,21 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID
9971116
9981117 // onSPANavigation records history when URL changes via JavaScript (History API).
9991118 // 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) {
10041120 // Update domain model with current URI for session snapshots
1005- c.updatePaneURI(paneID, url)
1121+ c.updatePaneURI(paneID, uri)
10061122
10071123 // Record history for SPA navigation
10081124 if c.onHistoryRecord != nil {
1009- c.onHistoryRecord(ctx, paneID, url)
1125+ c.onHistoryRecord(ctx, paneID, uri)
10101126 }
10111127 }
10121128
10131129 // updatePaneURI updates the pane's URI in the domain model.
10141130 // 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) {
10161132 if c.onPaneURIUpdated != nil {
1017- c.onPaneURIUpdated(paneID, url)
1133+ c.onPaneURIUpdated(paneID, uri)
10181134 }
10191135 }
10201136
@@ -1090,7 +1206,7 @@ func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
10901206 c.revealMu.Unlock()
10911207 }
10921208
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) {
10941210 c.revealMu.Lock()
10951211 pending := c.pendingReveal[paneID]
10961212 if pending {
@@ -1102,7 +1218,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.
11021218 return
11031219 }
11041220
1105- wv := c.webViews[paneID]
1221+ wv := c.getWebViewLocked(paneID)
11061222 if wv == nil || wv.IsDestroyed() {
11071223 return
11081224 }
@@ -1112,7 +1228,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.
11121228 logging.FromContext(ctx).
11131229 Debug().
11141230 Str("pane_id", string(paneID)).
1115- Str("url", url).
1231+ Str("uri", uri).
11161232 Str("reason", reason).
11171233 Msg("webview revealed")
11181234 }
@@ -1294,7 +1410,7 @@ func (c *ContentCoordinator) handlePopupCreate(
12941410 }
12951411
12961412 // Register WebView in our map (after successful insertion)
1297- c.webViews[paneID] = popupWV
1413+ c.setWebViewLocked(paneID, popupWV)
12981414
12991415 // Setup standard callbacks (after successful insertion to avoid leak)
13001416 c.setupWebViewCallbacks(ctx, paneID, popupWV)
@@ -1302,7 +1418,8 @@ func (c *ContentCoordinator) handlePopupCreate(
13021418 // Setup OAuth auto-close if configured
13031419 if hasConfig && oauthEnabled && isOAuth {
13041420 popupPane.AutoClose = true
1305- c.setupOAuthAutoClose(ctx, paneID, popupWV)
1421+ c.trackOAuthPopup(popupID, parentPaneID)
1422+ c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
13061423 log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
13071424 }
13081425
@@ -1326,9 +1443,9 @@ func (c *ContentCoordinator) handlePopupCreate(
13261443 popupWV.OnReadyToShow = func() {
13271444 c.handlePopupReadyToShow(ctx, popupID)
13281445 }
1329- popupWV.OnClose = func() {
1446+ popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
13301447 c.handlePopupClose(ctx, popupID)
1331- }
1448+ })
13321449
13331450 log.Info().
13341451 Uint64("popup_id", uint64(popupID)).
@@ -1389,6 +1506,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
13891506 c.popupMu.Unlock()
13901507
13911508 if wasPending && pending != nil {
1509+ c.handlePopupOAuthClose(ctx, popupID)
13921510 // Was never shown, just destroy
13931511 pending.WebView.Destroy()
13941512 log.Debug().Msg("destroyed pending popup that was never shown")
@@ -1405,6 +1523,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
14051523 }
14061524
14071525 if paneID == "" {
1526+ c.handlePopupOAuthClose(ctx, popupID)
14081527 log.Warn().Msg("popup close: could not find pane for webview")
14091528 return
14101529 }
@@ -1416,6 +1535,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
14161535 }
14171536 }
14181537
1538+ c.handlePopupOAuthClose(ctx, popupID)
1539+
14191540 // Release the WebView (this will clean up tracking)
14201541 c.ReleaseWebView(ctx, paneID)
14211542
@@ -1424,6 +1545,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
14241545
14251546 // setupWebViewCallbacks configures standard callbacks and popup handling.
14261547 func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1548+ log := logging.FromContext(ctx)
1549+
14271550 // Title changes
14281551 wv.OnTitleChanged = func(title string) {
14291552 c.onTitleChanged(ctx, paneID, title)
@@ -1453,7 +1576,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
14531576
14541577 // SPA navigation and external scheme handling
14551578 wv.OnURIChanged = func(uri string) {
1456- log := logging.FromContext(ctx)
14571579 if uri == "" {
14581580 return
14591581 }
@@ -1461,12 +1583,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
14611583 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
14621584 // These are typically triggered by JavaScript redirects (window.location)
14631585 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")
14701586
14711587 if isExternal {
14721588 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
15001616 c.onLinkHover(paneID, uri)
15011617 }
15021618
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+
15031649 // Fullscreen handlers for idle inhibition
15041650 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
15051651
@@ -1512,12 +1658,19 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
15121658 // For providers using postMessage (like Google Sign-In), we rely on the provider calling
15131659 // window.close() which triggers WebKit's close signal.
15141660 // 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+) {
15161667 log := logging.FromContext(ctx)
15171668
15181669 // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
15191670 var safetyTimer *time.Timer
15201671 var safetyTimerMu sync.Mutex
1672+ var cancelSafetyTimerOnce sync.Once
1673+ var requestCloseOnce sync.Once
15211674 const oauthSafetyTimeout = 30 * time.Second
15221675
15231676 startSafetyTimer := func() {
@@ -1535,69 +1688,166 @@ func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID ent
15351688 }
15361689
15371690 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+ })
15441699 }
15451700
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() {
15611709 go func() {
15621710 time.Sleep(500 * time.Millisecond)
15631711 if wv != nil && !wv.IsDestroyed() {
15641712 wv.Close()
15651713 }
15661714 }()
1567- }
1715+ })
15681716 }
15691717
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")
15751725 }
1726+ })
15761727
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) {
15781730 if event == webkit.LoadCommitted {
15791731 uri := wv.URI()
15801732 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")
15891734 }
15901735 }
1591- }
1736+ })
15921737
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() {
15961740 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
16001845 }
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")
16011851 }
16021852
16031853 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.
@@ -1643,7 +1893,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
16431893 newPane.URI = uri
16441894
16451895 // Register WebView
1646- c.webViews[paneID] = newWV
1896+ c.setWebViewLocked(paneID, newWV)
16471897
16481898 // Setup callbacks
16491899 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
233233 return
234234 }
235235
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))
237245 }
238246
239247 // 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
22
33 import (
44 "strings"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/webkit"
57 )
68
79 // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
@@ -138,3 +140,45 @@ func IsOAuthError(url string) bool {
138140 lower := strings.ToLower(url)
139141 return strings.Contains(lower, "error=")
140142 }
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 {
3535 onCloseLastPane func(ctx context.Context) error
3636 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
3737 onStateChanged func() // For session snapshots
38+ onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
3839 }
3940
4041 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -64,6 +65,16 @@ type stackPaneContext struct {
6465 originalTitle string
6566 }
6667
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+
6778 // NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
6879 func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
6980 log := logging.FromContext(ctx)
@@ -90,6 +101,11 @@ func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
90101 c.onStateChanged = fn
91102 }
92103
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+
93109 // notifyStateChanged triggers the state changed callback if set.
94110 func (c *WorkspaceCoordinator) notifyStateChanged() {
95111 if c.onStateChanged != nil {
@@ -717,52 +733,8 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
717733 return nil
718734 }
719735
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)
766738
767739 // Now do domain changes
768740 _, err := c.panesUC.Close(ctx, ws, activePane)
@@ -776,13 +748,11 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
776748 wsView,
777749 ws,
778750 closingPaneID,
779- parentNode,
780- siblingNode,
781- grandparentNode,
782- parentWidget,
783- siblingIsStartChild,
784- parentIsStartInGrand,
751+ closeCtx,
785752 )
753+ if c.onPaneClosed != nil {
754+ c.onPaneClosed(closingPaneID)
755+ }
786756
787757 // Notify state change for session snapshots
788758 c.notifyStateChanged()
@@ -824,35 +794,8 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
824794 return nil
825795 }
826796
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)
856799
857800 // Now do domain changes
858801 _, err := c.panesUC.Close(ctx, ws, paneNode)
@@ -866,13 +809,11 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
866809 wsView,
867810 ws,
868811 paneID,
869- parentNode,
870- siblingNode,
871- grandparentNode,
872- parentWidget,
873- siblingIsStartChild,
874- parentIsStartInGrand,
812+ closeCtx,
875813 )
814+ if c.onPaneClosed != nil {
815+ c.onPaneClosed(paneID)
816+ }
876817
877818 // Notify state change for session snapshots
878819 c.notifyStateChanged()
@@ -894,6 +835,16 @@ func (c *WorkspaceCoordinator) doIncrementalClose(
894835 parentIsStartInGrand bool, // true if parent is start/left child in grandparent
895836 ) error {
896837 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+
897848 tr := wsView.TreeRenderer()
898849 if tr == nil {
899850 return fmt.Errorf("tree renderer not available")
@@ -1015,12 +966,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1015966 wsView *component.WorkspaceView,
1016967 ws *entity.Workspace,
1017968 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,
1024970 ) {
1025971 if wsView == nil {
1026972 return
@@ -1028,29 +974,40 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1028974
1029975 log := logging.FromContext(ctx)
1030976
1031- if parentNode != nil {
977+ if closeCtx.parentNode != nil {
1032978 var closeErr error
1033979
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 {
1037985 closeErr = c.doIncrementalClose(
1038986 ctx,
1039987 wsView,
1040988 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,
1047995 )
1048996 } else {
1049997 closeErr = fmt.Errorf("missing context for incremental close")
1050998 }
1051999
10521000 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")
10541011 if err := wsView.Rebuild(ctx); err != nil {
10551012 log.Error().Err(err).Msg("failed to rebuild workspace view")
10561013 }
@@ -1073,6 +1030,113 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
10731030 wsView.FocusPane(ws.ActivePaneID)
10741031 }
10751032
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+
10761140 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
10771141 func (c *WorkspaceCoordinator) doIncrementalStackClose(
10781142 ctx context.Context,
@@ -1559,6 +1623,18 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
15591623 }
15601624 clickedPaneID := clickedChild.Pane.ID
15611625
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+
15621638 // Update StackedView active index
15631639 if err := sv.SetActive(ctx, clickedIndex); err != nil {
15641640 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 (
1818 // HoverCallback is called when a pane should receive focus from hover.
1919 type HoverCallback func(paneID entity.PaneID)
2020
21+// MotionCallback is called when intentional mouse movement is detected.
22+type MotionCallback func()
23+
2124 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.
2225 // It uses a debounce timer to avoid rapid focus switches.
2326 type HoverHandler struct {
2427 motionCtrl *gtk.EventControllerMotion
2528 paneID entity.PaneID
2629 onEnter HoverCallback
30+ onMotion MotionCallback
2731
2832 // 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)
3136
3237 timer *time.Timer
3338 timerMu sync.Mutex
@@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
5257 h.onEnter = fn
5358 }
5459
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+
5567 // AttachTo attaches the hover handler to a GTK widget.
5668 func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
5769 log := logging.FromContext(h.ctx)
@@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
7991 }
8092 h.motionCtrl.ConnectLeave(&h.leaveCb)
8193
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+
82104 // Add controller to widget
83105 widget.AddController(&h.motionCtrl.EventController)
84106
@@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() {
143165 h.motionCtrl = nil
144166 h.enterCb = nil
145167 h.leaveCb = nil
168+ h.motionCb = nil
146169 }
M internal/ui/layout/gtk_widgets.go +3 -3
@@ -159,14 +159,14 @@ func (p *gtkPaned) GetShrinkEndChild() bool { return p.inner.GetShrinkEndChil
159159 func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) }
160160 func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() }
161161
162-func (p *gtkPaned) ConnectMap(callback func()) uint32 {
162+func (p *gtkPaned) ConnectMap(callback func()) uint {
163163 cb := func(_ gtk.Widget) {
164164 callback()
165165 }
166166 return p.inner.ConnectMap(&cb)
167167 }
168168
169-func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
169+func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {
170170 cb := func(_ gobject.Object, _ uintptr) {
171171 callback()
172172 }
@@ -539,7 +539,7 @@ func (btn *gtkButton) GetChild() Widget {
539539 return &gtkWidget{inner: child}
540540 }
541541
542-func (btn *gtkButton) ConnectClicked(callback func()) uint32 {
542+func (btn *gtkButton) ConnectClicked(callback func()) uint {
543543 cb := func(_ gtk.Button) {
544544 callback()
545545 }
M internal/ui/layout/mocks/mocks.go +18 -18
@@ -1485,18 +1485,18 @@ func (_c *MockPanedWidget_ComputePoint_Call) RunAndReturn(run func(target layout
14851485 }
14861486
14871487 // 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 {
14891489 ret := _mock.Called(callback)
14901490
14911491 if len(ret) == 0 {
14921492 panic("no return value specified for ConnectMap")
14931493 }
14941494
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 {
14971497 r0 = returnFunc(callback)
14981498 } else {
1499- r0 = ret.Get(0).(uint32)
1499+ r0 = ret.Get(0).(uint)
15001500 }
15011501 return r0
15021502 }
@@ -1525,29 +1525,29 @@ func (_c *MockPanedWidget_ConnectMap_Call) Run(run func(callback func())) *MockP
15251525 return _c
15261526 }
15271527
1528-func (_c *MockPanedWidget_ConnectMap_Call) Return(v uint32) *MockPanedWidget_ConnectMap_Call {
1528+func (_c *MockPanedWidget_ConnectMap_Call) Return(v uint) *MockPanedWidget_ConnectMap_Call {
15291529 _c.Call.Return(v)
15301530 return _c
15311531 }
15321532
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 {
15341534 _c.Call.Return(run)
15351535 return _c
15361536 }
15371537
15381538 // 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 {
15401540 ret := _mock.Called(callback)
15411541
15421542 if len(ret) == 0 {
15431543 panic("no return value specified for ConnectNotifyPosition")
15441544 }
15451545
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 {
15481548 r0 = returnFunc(callback)
15491549 } else {
1550- r0 = ret.Get(0).(uint32)
1550+ r0 = ret.Get(0).(uint)
15511551 }
15521552 return r0
15531553 }
@@ -1576,12 +1576,12 @@ func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func
15761576 return _c
15771577 }
15781578
1579-func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1579+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint) *MockPanedWidget_ConnectNotifyPosition_Call {
15801580 _c.Call.Return(v)
15811581 return _c
15821582 }
15831583
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 {
15851585 _c.Call.Return(run)
15861586 return _c
15871587 }
@@ -8007,18 +8007,18 @@ func (_c *MockButtonWidget_ComputePoint_Call) RunAndReturn(run func(target layou
80078007 }
80088008
80098009 // 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 {
80118011 ret := _mock.Called(callback)
80128012
80138013 if len(ret) == 0 {
80148014 panic("no return value specified for ConnectClicked")
80158015 }
80168016
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 {
80198019 r0 = returnFunc(callback)
80208020 } else {
8021- r0 = ret.Get(0).(uint32)
8021+ r0 = ret.Get(0).(uint)
80228022 }
80238023 return r0
80248024 }
@@ -8047,12 +8047,12 @@ func (_c *MockButtonWidget_ConnectClicked_Call) Run(run func(callback func())) *
80478047 return _c
80488048 }
80498049
8050-func (_c *MockButtonWidget_ConnectClicked_Call) Return(v uint32) *MockButtonWidget_ConnectClicked_Call {
8050+func (_c *MockButtonWidget_ConnectClicked_Call) Return(v uint) *MockButtonWidget_ConnectClicked_Call {
80518051 _c.Call.Return(v)
80528052 return _c
80538053 }
80548054
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 {
80568056 _c.Call.Return(run)
80578057 return _c
80588058 }
M internal/ui/layout/split_test.go +2 -2
@@ -29,10 +29,10 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or
2929 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
3030 }
3131
32- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
32+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
3333
3434 // 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()
3636 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
3737 }
3838
M internal/ui/layout/stacked.go +3 -3
@@ -35,7 +35,7 @@ type stackedPane struct {
3535 isActive bool
3636
3737 // Signal handler IDs for cleanup on removal
38- closeClickSignalID uint32
38+ closeClickSignalID uint
3939 closeButton ButtonWidget // stored for signal disconnection
4040
4141 // Retained callback for GestureClick to prevent GC
@@ -183,7 +183,7 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
183183 // Returns the retained callback (to prevent GC) and close signal ID for disconnection.
184184 func (sv *StackedView) connectTitleBarHandlers(
185185 tb titleBarComponents, paneID string,
186-) (titleClickCallback interface{}, closeSignalID uint32) {
186+) (titleClickCallback interface{}, closeSignalID uint) {
187187 // Connect title bar click handler using GestureClick
188188 // This prevents event propagation issues with nested buttons
189189 clickCtrl := gtk.NewGestureClick()
@@ -259,7 +259,7 @@ func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) {
259259
260260 // disconnectButtonSignal safely disconnects a signal from a button widget.
261261 // 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) {
263263 if btn == nil || signalID == 0 {
264264 return
265265 }
M internal/ui/layout/stacked_test.go +2 -2
@@ -70,7 +70,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
7070 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
7171
7272 // Close button click handler
73- mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
73+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
7474
7575 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
7676 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
@@ -593,7 +593,7 @@ func setupInsertPaneMocks(
593593 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
594594
595595 // Close button click handler
596- mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()
596+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
597597
598598 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
599599 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) {
122122 mockRightStackBox.EXPECT().SetVisible(true).Once()
123123 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
124124 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()
126126 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()
128128 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
129129
130130 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -172,9 +172,9 @@ func TestBuild_VerticalSplit(t *testing.T) {
172172 mockBottomStackBox.EXPECT().SetVisible(true).Once()
173173 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()
174174 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()
176176 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()
178178 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
179179
180180 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -241,9 +241,9 @@ func TestBuild_NestedSplits(t *testing.T) {
241241 mockStackBox2.EXPECT().SetVisible(true).Once()
242242 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()
243243 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()
245245 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()
247247 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
248248
249249 // Outer split (horizontal) gets inner paned and pane3
@@ -255,9 +255,9 @@ func TestBuild_NestedSplits(t *testing.T) {
255255 mockStackBox3.EXPECT().SetVisible(true).Once()
256256 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
257257 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()
259259 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()
261261 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
262262
263263 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -417,9 +417,9 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
417417 mockRightStackBox.EXPECT().SetVisible(true).Once()
418418 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
419419 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()
421421 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()
423423 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
424424
425425 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -542,9 +542,9 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
542542 mockRightStackBox.EXPECT().SetVisible(true).Once()
543543 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
544544 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()
546546 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()
548548
549549 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
550550
M internal/ui/layout/widgets.go +3 -3
@@ -95,8 +95,8 @@ type PanedWidget interface {
9595 GetShrinkEndChild() bool
9696
9797 // Signals
98- ConnectMap(callback func()) uint32
99- ConnectNotifyPosition(callback func()) uint32
98+ ConnectMap(callback func()) uint
99+ ConnectNotifyPosition(callback func()) uint
100100
101101 // Tick callback for frame-based updates (returns signal ID)
102102 // Callback returns true to continue, false to stop
@@ -182,7 +182,7 @@ type ButtonWidget interface {
182182 SetIconName(iconName string)
183183
184184 // Connect click handler, returns signal ID for disconnection
185- ConnectClicked(callback func()) uint32
185+ ConnectClicked(callback func()) uint
186186 }
187187
188188 // 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 {
527527 min-width: 32px;
528528 min-height: 32px;
529529 color: var(--muted);
530+ opacity: 0.4;
530531 }
531532
532533 .loading-skeleton-logo {
533534 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);
536543 }
537544
538545 /* Pane border - default transparent */
M webui/package-lock.json +14 -1 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close