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
107 aur/*/LICENSE107 aur/*/LICENSE
108 aur/*/dev.bnema.Dumber.desktop108 aur/*/dev.bnema.Dumber.desktop
109 aur/*/logo-512.png109 aur/*/logo-512.png
110+
111+# Planning/runbook notes stored outside product docs
112+docs/reference/stability-performance-multi-agent-runbook-*.md
M CHANGELOG.md +51 -0
@@ -2,6 +2,57 @@
2 2
3 All notable changes to this project will be documented in this file.3 All notable changes to this project will be documented in this file.
4 4
5+## [Unreleased]
6+
7+### Added
8+
9+- **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.
10+- **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.
11+- **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior.
12+
13+### Changed
14+
15+- **WebKit/puregotk compatibility refresh**: Updated bindings and call sites for `github.com/bnema/puregotk-webkit v0.0.10` and latest puregotk replacement, including signal ID/type handling updates.
16+- **Dependencies updated**: Bumped key runtime/developer dependencies, including `go-sqlite3`, `bubbles`, and related transitive modules.
17+- **Omnibox behavior tuning**: Refined ghost suggestion ranking/selection behavior and dynamic result sizing tied to pane height.
18+- **Autocomplete architecture cleanup**: Centralized completion flow and ghost positioning helpers for more consistent behavior across async paths.
19+- **Omnibox keyboard/navigation UX**: Added `Alt+h/j/k/l` pane navigation while omnibox is open and improved chrome height scaling/debouncing on GTK main thread.
20+- **Config normalization hardening**: Normalization now coerces invalid profile/appearance values to safe defaults.
21+- **Loading skeleton redesign**: Reduced logo from 512px to 256px, added version label below a discrete 32px spinner. Sizes extracted to constants so tests use `mock.Anything` and don't break on dimension changes.
22+- **Aggressive WebView reuse policy**: `reusePolicyAggressive` now actually differs from `reusePolicySafe` by allowing reuse regardless of navigation activity, relying on `ResetForPoolReuse` to clear state.
23+
24+### Fixed
25+
26+- **Hover focus stealing from new split panes**: New pane creation now locks hover focus until the user intentionally moves the mouse, replacing the 300ms time-based suppression that could expire before GTK layout completes. Synthetic enter events from widget rearrangement no longer steal focus from freshly created panes.
27+- **Stacked pane title desync on navigation**: Titles in the stacked title bar now sync immediately on `LoadCommitted` and when switching panes, preventing stale titles after background navigation.
28+- **Session marker file accumulation**: `writeShutdownMarker` now removes the matching startup marker (embedding its timestamp for classification), and a new `sweepPairedMarkers` sweep in background cleanup removes old marker trios past the configured age.
29+- **Retry delay exceeding max**: `retryDelayForAttempt` now clamps the final delay (base + jitter) to `retryMaxDelay` so backoff never overshoots the hard cap.
30+- **Inconsistent WebView termination in pool**: `PrewarmFirst`, `PrewarmAsync`, `prewarmSync`, and `RefreshScripts` now call `DestroyWithPolicy(terminatePolicy)` instead of bare `Destroy()`, matching `Release` behavior.
31+- **Pool metrics counted on failed creation**: `acquireCount`, `acquirePoolMisses`, and `acquireTotalNanos` are now incremented only after `createWebView` succeeds.
32+- **Redundant crash page URI sanitization**: Removed double `sanitizeCrashPageOriginalURI` call inside `buildCrashPageHTML`; the caller already sanitizes.
33+- **Racy skeleton version global**: Replaced plain `string` with `atomic.Value` so concurrent reads/writes to `skeletonVersion` are safe.
34+- **Inline ghost suggestion stability**: Reworked ghostwriter flow to keep suggestions selected (not auto-committed), with stronger token guards and stale-callback protection.
35+- **GTK thread-safety in omnibox**: Removed unsafe off-main-thread widget reads in async flows.
36+- **Review follow-ups (Copilot/CodeRabbit)**: Fixed custom-scheme GET handling for empty WebKit methods, synchronized pool close/send paths, aligned WebKit signal ID storage types, and marshalled OAuth parent refresh reloads back to the GTK main loop.
37+- **Persistence/update safety edges**: Enforced positive visit-count deltas in SQLite repos, removed redundant filter cache directory creation, canceled pending snapshot timer on `SetReady`, and made updater retry-loop exhaustion explicit.
38+- **Logging/dedup consistency**: Unified abrupt-session marker stat warnings under structured session logging and reduced favicon warning dedup growth by using a global cache-dir warning key plus callback coverage tests.
39+- **Omnibox input/ghost correctness**: Enforced minimum typed input before ghost suggestions, trimmed leading spaces before URL interpretation, deferred omnibox auto-open until layout is ready, and fixed width/layout edge-cases.
40+- **History/autocomplete edge-cases**: Normalized FTS queries containing slashes and capped `about:blank` visit-count inflation.
41+- **Navigation/history robustness**: Added pane history cleanup hooks, batched/capped visit increment fallback, and idempotent `NavigateUseCase.Close()`.
42+- **WebView lifecycle/reset correctness**: Reset pooled WebView transient state more thoroughly and improved callback/signal wiring consistency.
43+- **Navigation safety guards**: Added `CanGoBack` protection in navigation policy handling and corrected back-forward-list signal disconnection target.
44+- **Crash handling and retry edge-cases**: Hardened crash-page routing safety, updater retry semantics/documentation, and related infra error paths.
45+- **Filtering/snapshot/session reliability**: Fixed race/error-handling paths in filtering updates, snapshot persistence, and session startup marker processing.
46+- **WebView pool shutdown race**: Re-check `closed` flag after `createWebView` in `Acquire` to prevent leaking WebViews when pool closes during creation.
47+- **Updater transient error detection**: Broadened `isRetryableRequestError` to detect `ECONNRESET`, `ECONNREFUSED`, `ENETUNREACH`, `EHOSTUNREACH`, and `EADDRNOTAVAIL` syscall errors, plus `net.Error.Temporary()` and `url.Error` wrappers.
48+- **Session lock probe accuracy**: `markAbruptExits` now uses non-blocking flock instead of `os.Stat`, correctly distinguishing active sessions (lock held) from stale ones (lock acquirable) and skipping on permission/IO errors.
49+- **Crash page "Stay on this page" UX**: Button no longer navigates to `dumb://home/crash`; it now stays on the crash page as its label suggests.
50+- **Visit increment fallback overflow**: `IncrementVisitCountBy` now returns an error when delta exceeds the fallback cap instead of silently dropping increments.
51+
52+### Security
53+
54+- **External scheme launch guard**: Restricted external URL scheme launching to user-gesture initiated navigations.
55+
5 ## [0.26.1] - 2026-01-2556 ## [0.26.1] - 2026-01-25
6 57
7 ### Fixed58 ### Fixed
M cmd/dumber/main.go +2 -0
@@ -28,6 +28,7 @@ import (
28 "github.com/bnema/dumber/internal/infrastructure/xdg"28 "github.com/bnema/dumber/internal/infrastructure/xdg"
29 "github.com/bnema/dumber/internal/logging"29 "github.com/bnema/dumber/internal/logging"
30 "github.com/bnema/dumber/internal/ui"30 "github.com/bnema/dumber/internal/ui"
31+ "github.com/bnema/dumber/internal/ui/component"
31 "github.com/bnema/dumber/internal/ui/theme"32 "github.com/bnema/dumber/internal/ui/theme"
32 "github.com/rs/zerolog"33 "github.com/rs/zerolog"
33 )34 )
@@ -71,6 +72,7 @@ func main() {
71 72
72 func runGUI() int {73 func runGUI() int {
73 runtime.LockOSThread()74 runtime.LockOSThread()
75+ component.SetSkeletonVersion(version)
74 timer := bootstrap.NewStartupTimer()76 timer := bootstrap.NewStartupTimer()
75 77
76 cfg := initConfig()78 cfg := initConfig()
M docs/config/index.md +16 -5
@@ -432,12 +432,23 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal
432 [media]432 [media]
433 hardware_decoding = "auto" # HW preferred, SW fallback433 hardware_decoding = "auto" # HW preferred, SW fallback
434 prefer_av1 = false # Let site choose codec434 prefer_av1 = false # Let site choose codec
435-show_diagnostics = false # Enable for debugging435+show_diagnostics = false # Keep off for daily use
436 force_vsync = false # Let compositor handle VSync436 force_vsync = false # Let compositor handle VSync
437 gl_rendering_mode = "auto" # GStreamer picks best GL API437 gl_rendering_mode = "auto" # GStreamer picks best GL API
438 gstreamer_debug_level = 0 # Increase to 3-5 for debugging438 gstreamer_debug_level = 0 # Increase to 3-5 for debugging
439 ```439 ```
440 440
441+**Diagnostic mode toggle path:**
442+
443+```toml
444+# ~/.config/dumber/config.toml
445+[media]
446+show_diagnostics = true
447+gstreamer_debug_level = 3
448+```
449+
450+Set `show_diagnostics = false` and `gstreamer_debug_level = 0` to return to normal mode.
451+
441 **Diagnostics CLI:**452 **Diagnostics CLI:**
442 ```bash453 ```bash
443 # Check GStreamer plugins and VA-API status454 # Check GStreamer plugins and VA-API status
@@ -529,7 +540,7 @@ Performance profiles provide preset configurations for WebKitGTK tuning. These s
529 540
530 | Profile | Description | Use Case |541 | Profile | Description | Use Case |
531 |---------|-------------|----------|542 |---------|-------------|----------|
532-| `default` | No tuning, uses WebKit defaults | Normal browsing, most users |543+| `default` | No tuning, uses WebKit defaults | Compatibility baseline, troubleshooting |
533 | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |544 | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |
534 | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |545 | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |
535 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |546 | `custom` | Manual control over all settings | Advanced users who want fine-grained control |
@@ -549,10 +560,10 @@ Performance profiles provide preset configurations for WebKitGTK tuning. These s
549 **Example:**560 **Example:**
550 ```toml561 ```toml
551 [performance]562 [performance]
552-profile = "default" # Most users - no tuning needed563+profile = "default" # WebKit baseline default
553 564
554-# For low-RAM systems:565+# For WebKit baseline behavior:
555-# profile = "lite"566+# profile = "default"
556 567
557 # For heavy pages (GitHub PRs, complex web apps):568 # For heavy pages (GitHub PRs, complex web apps):
558 # profile = "max"569 # profile = "max"
M docs/reference/index.md +1 -0
@@ -6,6 +6,7 @@
6 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu6 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
7 - [Keybindings](./keybindings.md) - All keyboard shortcuts7 - [Keybindings](./keybindings.md) - All keyboard shortcuts
8 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions8 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
9+- [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation
9 10
10 ## Related11 ## Related
11 12
A docs/reference/session-exit-classification-runbook.md +92 -0
@@ -0,0 +1,92 @@
1+# Session Exit Classification Runbook
2+
3+This runbook classifies browser sessions using existing P0 marker files in the log directory:
4+
5+- `session_<id>.startup.marker`
6+- `session_<id>.shutdown.marker`
7+- `session_<id>.abrupt.marker`
8+
9+Marker directory (default):
10+
11+```bash
12+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
13+```
14+
15+## Classification Model
16+
17+- `clean_exit` (`marker-confirmed`)
18+ - `shutdown.marker` is present.
19+- `main_process_crash_or_abrupt` (`marker-confirmed`)
20+ - `abrupt.marker` is present and no `shutdown.marker` exists.
21+- `external_kill_or_oom_inferred` (`best-effort`)
22+ - `startup.marker` exists, but `shutdown.marker` and `abrupt.marker` are both missing.
23+ - This is explicitly inferred, not confirmed.
24+
25+Implementation path in code:
26+
27+- `internal/bootstrap/session_exit_classification.go`
28+ - `ClassifySessionExitFromMarkers(lockDir, sessionID)`
29+ - `BuildSessionExitReport(lockDir)`
30+
31+## Quick Marker Report (Shell)
32+
33+```bash
34+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
35+
36+for startup in "$LOG_DIR"/session_*.startup.marker; do
37+ [ -e "$startup" ] || continue
38+ base="$(basename "$startup")"
39+ sid="${base#session_}"
40+ sid="${sid%.startup.marker}"
41+ shutdown="$LOG_DIR/session_${sid}.shutdown.marker"
42+ abrupt="$LOG_DIR/session_${sid}.abrupt.marker"
43+
44+ if [ -f "$shutdown" ]; then
45+ class="clean_exit"
46+ inf="marker-confirmed"
47+ elif [ -f "$abrupt" ]; then
48+ class="main_process_crash_or_abrupt"
49+ inf="marker-confirmed"
50+ else
51+ class="external_kill_or_oom_inferred"
52+ inf="best-effort"
53+ fi
54+
55+ printf "%s\t%s\t%s\n" "$sid" "$class" "$inf"
56+done | sort
57+```
58+
59+## Optional System-Log Correlation
60+
61+Use this only when `external_kill_or_oom_inferred` needs more evidence.
62+
63+1. Get the startup timestamp for a session:
64+
65+```bash
66+SID="<session-id>"
67+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
68+cat "$LOG_DIR/session_${SID}.startup.marker"
69+```
70+
71+2. Inspect user journal around that window:
72+
73+```bash
74+journalctl --user --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "dumber|killed|oom|sigkill|signal"
75+```
76+
77+3. Inspect kernel/system OOM evidence:
78+
79+```bash
80+journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "oom|out of memory|killed process"
81+```
82+
83+4. Optional coredump check:
84+
85+```bash
86+coredumpctl list | rg -i "dumber"
87+```
88+
89+Interpretation guidance:
90+
91+- OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`.
92+- No OOM evidence does not prove a clean exit; keep inference label explicit.
M docs/reference/troubleshooting.md +20 -2
@@ -29,16 +29,34 @@ dumber doctor --media # GStreamer/VA-API only
29 29
30 **Solution:**30 **Solution:**
31 1. Run `dumber doctor --media`31 1. Run `dumber doctor --media`
32-2. Check hardware decoding:32+2. Enable diagnostic mode for startup media warnings:
33+
34+ ```toml
35+ # ~/.config/dumber/config.toml
36+ [media]
37+ show_diagnostics = true
38+ gstreamer_debug_level = 3
39+ ```
40+
41+3. Check hardware decoding:
42+
33 ```toml43 ```toml
34 # ~/.config/dumber/config.toml44 # ~/.config/dumber/config.toml
35 [media]45 [media]
36 hardware_decoding = "auto" # Try "disable" if issues persist46 hardware_decoding = "auto" # Try "disable" if issues persist
37 ```47 ```
38-3. Install VA-API drivers:48+
49+4. Install VA-API drivers:
39 - AMD: `libva-mesa-driver`50 - AMD: `libva-mesa-driver`
40 - Intel: `intel-media-driver`51 - Intel: `intel-media-driver`
41 - NVIDIA: `libva-nvidia-driver`52 - NVIDIA: `libva-nvidia-driver`
53+5. After diagnosing, disable diagnostic mode for quieter daily runs:
54+
55+ ```toml
56+ [media]
57+ show_diagnostics = false
58+ gstreamer_debug_level = 0
59+ ```
42 60
43 ### Screen flickering on Wayland61 ### Screen flickering on Wayland
44 62
M go.mod +3 -3
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.96+ github.com/bnema/puregotk-webkit v0.0.10
7 github.com/charmbracelet/bubbles v0.21.17 github.com/charmbracelet/bubbles v0.21.1
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,13 +22,13 @@ require (
22 gopkg.in/yaml.v3 v3.0.122 gopkg.in/yaml.v3 v3.0.1
23 )23 )
24 24
25-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa325+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3
26 26
27 require (27 require (
28 github.com/atotto/clipboard v0.1.4 // indirect28 github.com/atotto/clipboard v0.1.4 // indirect
29 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect29 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
30 github.com/charmbracelet/colorprofile v0.4.1 // indirect30 github.com/charmbracelet/colorprofile v0.4.1 // indirect
31- github.com/charmbracelet/x/ansi v0.11.5 // indirect31+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
32 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect32 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
33 github.com/charmbracelet/x/term v0.2.2 // indirect33 github.com/charmbracelet/x/term v0.2.2 // indirect
34 github.com/clipperhouse/displaywidth v0.9.0 // indirect34 github.com/clipperhouse/displaywidth v0.9.0 // indirect
M go.sum +6 -6 generated file: collapsed
M internal/application/port/mocks/mocks.go +159 -0
@@ -1281,6 +1281,165 @@ func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool)
1281 return _c1281 return _c
1282 }1282 }
1283 1283
1284+// NewMockDownloadResponse creates a new instance of MockDownloadResponse. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1285+// The first argument is typically a *testing.T value.
1286+func NewMockDownloadResponse(t interface {
1287+ mock.TestingT
1288+ Cleanup(func())
1289+}) *MockDownloadResponse {
1290+ mock := &MockDownloadResponse{}
1291+ mock.Mock.Test(t)
1292+
1293+ t.Cleanup(func() { mock.AssertExpectations(t) })
1294+
1295+ return mock
1296+}
1297+
1298+// MockDownloadResponse is an autogenerated mock type for the DownloadResponse type
1299+type MockDownloadResponse struct {
1300+ mock.Mock
1301+}
1302+
1303+type MockDownloadResponse_Expecter struct {
1304+ mock *mock.Mock
1305+}
1306+
1307+func (_m *MockDownloadResponse) EXPECT() *MockDownloadResponse_Expecter {
1308+ return &MockDownloadResponse_Expecter{mock: &_m.Mock}
1309+}
1310+
1311+// GetMimeType provides a mock function for the type MockDownloadResponse
1312+func (_mock *MockDownloadResponse) GetMimeType() string {
1313+ ret := _mock.Called()
1314+
1315+ if len(ret) == 0 {
1316+ panic("no return value specified for GetMimeType")
1317+ }
1318+
1319+ var r0 string
1320+ if returnFunc, ok := ret.Get(0).(func() string); ok {
1321+ r0 = returnFunc()
1322+ } else {
1323+ r0 = ret.Get(0).(string)
1324+ }
1325+ return r0
1326+}
1327+
1328+// MockDownloadResponse_GetMimeType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMimeType'
1329+type MockDownloadResponse_GetMimeType_Call struct {
1330+ *mock.Call
1331+}
1332+
1333+// GetMimeType is a helper method to define mock.On call
1334+func (_e *MockDownloadResponse_Expecter) GetMimeType() *MockDownloadResponse_GetMimeType_Call {
1335+ return &MockDownloadResponse_GetMimeType_Call{Call: _e.mock.On("GetMimeType")}
1336+}
1337+
1338+func (_c *MockDownloadResponse_GetMimeType_Call) Run(run func()) *MockDownloadResponse_GetMimeType_Call {
1339+ _c.Call.Run(func(args mock.Arguments) {
1340+ run()
1341+ })
1342+ return _c
1343+}
1344+
1345+func (_c *MockDownloadResponse_GetMimeType_Call) Return(s string) *MockDownloadResponse_GetMimeType_Call {
1346+ _c.Call.Return(s)
1347+ return _c
1348+}
1349+
1350+func (_c *MockDownloadResponse_GetMimeType_Call) RunAndReturn(run func() string) *MockDownloadResponse_GetMimeType_Call {
1351+ _c.Call.Return(run)
1352+ return _c
1353+}
1354+
1355+// GetSuggestedFilename provides a mock function for the type MockDownloadResponse
1356+func (_mock *MockDownloadResponse) GetSuggestedFilename() string {
1357+ ret := _mock.Called()
1358+
1359+ if len(ret) == 0 {
1360+ panic("no return value specified for GetSuggestedFilename")
1361+ }
1362+
1363+ var r0 string
1364+ if returnFunc, ok := ret.Get(0).(func() string); ok {
1365+ r0 = returnFunc()
1366+ } else {
1367+ r0 = ret.Get(0).(string)
1368+ }
1369+ return r0
1370+}
1371+
1372+// MockDownloadResponse_GetSuggestedFilename_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSuggestedFilename'
1373+type MockDownloadResponse_GetSuggestedFilename_Call struct {
1374+ *mock.Call
1375+}
1376+
1377+// GetSuggestedFilename is a helper method to define mock.On call
1378+func (_e *MockDownloadResponse_Expecter) GetSuggestedFilename() *MockDownloadResponse_GetSuggestedFilename_Call {
1379+ return &MockDownloadResponse_GetSuggestedFilename_Call{Call: _e.mock.On("GetSuggestedFilename")}
1380+}
1381+
1382+func (_c *MockDownloadResponse_GetSuggestedFilename_Call) Run(run func()) *MockDownloadResponse_GetSuggestedFilename_Call {
1383+ _c.Call.Run(func(args mock.Arguments) {
1384+ run()
1385+ })
1386+ return _c
1387+}
1388+
1389+func (_c *MockDownloadResponse_GetSuggestedFilename_Call) Return(s string) *MockDownloadResponse_GetSuggestedFilename_Call {
1390+ _c.Call.Return(s)
1391+ return _c
1392+}
1393+
1394+func (_c *MockDownloadResponse_GetSuggestedFilename_Call) RunAndReturn(run func() string) *MockDownloadResponse_GetSuggestedFilename_Call {
1395+ _c.Call.Return(run)
1396+ return _c
1397+}
1398+
1399+// GetUri provides a mock function for the type MockDownloadResponse
1400+func (_mock *MockDownloadResponse) GetUri() string {
1401+ ret := _mock.Called()
1402+
1403+ if len(ret) == 0 {
1404+ panic("no return value specified for GetUri")
1405+ }
1406+
1407+ var r0 string
1408+ if returnFunc, ok := ret.Get(0).(func() string); ok {
1409+ r0 = returnFunc()
1410+ } else {
1411+ r0 = ret.Get(0).(string)
1412+ }
1413+ return r0
1414+}
1415+
1416+// MockDownloadResponse_GetUri_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUri'
1417+type MockDownloadResponse_GetUri_Call struct {
1418+ *mock.Call
1419+}
1420+
1421+// GetUri is a helper method to define mock.On call
1422+func (_e *MockDownloadResponse_Expecter) GetUri() *MockDownloadResponse_GetUri_Call {
1423+ return &MockDownloadResponse_GetUri_Call{Call: _e.mock.On("GetUri")}
1424+}
1425+
1426+func (_c *MockDownloadResponse_GetUri_Call) Run(run func()) *MockDownloadResponse_GetUri_Call {
1427+ _c.Call.Run(func(args mock.Arguments) {
1428+ run()
1429+ })
1430+ return _c
1431+}
1432+
1433+func (_c *MockDownloadResponse_GetUri_Call) Return(s string) *MockDownloadResponse_GetUri_Call {
1434+ _c.Call.Return(s)
1435+ return _c
1436+}
1437+
1438+func (_c *MockDownloadResponse_GetUri_Call) RunAndReturn(run func() string) *MockDownloadResponse_GetUri_Call {
1439+ _c.Call.Return(run)
1440+ return _c
1441+}
1442+
1284 // NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1443 // NewMockFontDetector creates a new instance of MockFontDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1285 // The first argument is typically a *testing.T value.1444 // The first argument is typically a *testing.T value.
1286 func NewMockFontDetector(t interface {1445 func NewMockFontDetector(t interface {
M internal/application/port/updater.go +4 -0
@@ -3,10 +3,14 @@ package port
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 )9 )
9 10
11+// ErrUpdateCheckTransient indicates a temporary update-check failure that should not be treated as actionable.
12+var ErrUpdateCheckTransient = errors.New("transient update check failure")
13+
10 // UpdateChecker checks for available updates from a remote source.14 // UpdateChecker checks for available updates from a remote source.
11 type UpdateChecker interface {15 type UpdateChecker interface {
12 // CheckForUpdate compares the current version with the latest available release.16 // CheckForUpdate compares the current version with the latest available release.
M internal/application/port/webkit_context.go +17 -0
@@ -3,6 +3,15 @@ package port
3 3
4 import "context"4 import "context"
5 5
6+// WebKitCookiePolicy controls cookie acceptance behavior for NetworkSession.
7+type WebKitCookiePolicy string
8+
9+const (
10+ WebKitCookiePolicyAlways WebKitCookiePolicy = "always"
11+ WebKitCookiePolicyNoThirdParty WebKitCookiePolicy = "no_third_party"
12+ WebKitCookiePolicyNever WebKitCookiePolicy = "never"
13+)
14+
6 // MemoryPressureConfig holds memory pressure settings for a WebKit process.15 // MemoryPressureConfig holds memory pressure settings for a WebKit process.
7 // Zero values mean "use WebKit defaults".16 // Zero values mean "use WebKit defaults".
8 type MemoryPressureConfig struct {17 type MemoryPressureConfig struct {
@@ -42,6 +51,14 @@ type WebKitContextOptions struct {
42 // CacheDir is the directory for cache data.51 // CacheDir is the directory for cache data.
43 CacheDir string52 CacheDir string
44 53
54+ // CookiePolicy controls cookie acceptance behavior.
55+ // Empty value means runtime defaults are used.
56+ CookiePolicy WebKitCookiePolicy
57+
58+ // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
59+ // False means ITP remains disabled unless enabled explicitly.
60+ ITPEnabled bool
61+
45 // WebProcessMemory configures memory pressure for web processes.62 // WebProcessMemory configures memory pressure for web processes.
46 // nil means use WebKit defaults.63 // nil means use WebKit defaults.
47 WebProcessMemory *MemoryPressureConfig64 WebProcessMemory *MemoryPressureConfig
M internal/application/port/webview.go +16 -4
@@ -52,6 +52,16 @@ type WebViewState struct {
52 ZoomLevel float6452 ZoomLevel float64
53 }53 }
54 54
55+// WebProcessTerminationReason describes why the web process terminated.
56+type WebProcessTerminationReason int
57+
58+const (
59+ WebProcessTerminationUnknown WebProcessTerminationReason = iota
60+ WebProcessTerminationCrashed
61+ WebProcessTerminationExceededMemory
62+ WebProcessTerminationByAPI
63+)
64+
55 // PopupRequest contains metadata about a popup window request.65 // PopupRequest contains metadata about a popup window request.
56 type PopupRequest struct {66 type PopupRequest struct {
57 TargetURI string67 TargetURI string
@@ -92,6 +102,8 @@ type WebViewCallbacks struct {
92 // OnLinkHover is called when hovering over a link, image, or media element.102 // OnLinkHover is called when hovering over a link, image, or media element.
93 // The uri parameter contains the target URL, or empty string when leaving.103 // The uri parameter contains the target URL, or empty string when leaving.
94 OnLinkHover func(uri string)104 OnLinkHover func(uri string)
105+ // OnWebProcessTerminated is called when the web process exits unexpectedly.
106+ OnWebProcessTerminated func(reason WebProcessTerminationReason, reasonLabel string, uri string)
95 }107 }
96 108
97 // FindOptions configures search behavior.109 // FindOptions configures search behavior.
@@ -111,10 +123,10 @@ type FindController interface {
111 GetSearchText() string123 GetSearchText() string
112 124
113 // Signal connections125 // Signal connections
114- OnFoundText(callback func(matchCount uint)) uint32126+ OnFoundText(callback func(matchCount uint)) uint
115- OnFailedToFindText(callback func()) uint32127+ OnFailedToFindText(callback func()) uint
116- OnCountedMatches(callback func(matchCount uint)) uint32128+ OnCountedMatches(callback func(matchCount uint)) uint
117- DisconnectSignal(id uint32)129+ DisconnectSignal(id uint)
118 }130 }
119 131
120 // WebView defines the port interface for browser view operations.132 // WebView defines the port interface for browser view operations.
M internal/application/usecase/autocomplete.go +74 -41
@@ -157,22 +157,21 @@ func (uc *AutocompleteUseCase) findFavoriteSuggestion(ctx context.Context, input
157 return nil157 return nil
158 }158 }
159 159
160- for _, fav := range favorites {160+ return bestSuggestionFromURLs(input, autocomplete.SourceFavorite,
161- if fav == nil || fav.URL == "" {161+ len(favorites),
162- continue162+ func(i int) string {
163- }163+ if favorites[i] == nil {
164- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, fav.URL)164+ return ""
165- if ok {
166- return &autocomplete.Suggestion{
167- FullText: matchedURL,
168- Suffix: suffix,
169- Source: autocomplete.SourceFavorite,
170- Title: fav.Title,
171 }165 }
172- }166+ return favorites[i].URL
173- }167+ },
174-168+ func(i int) string {
175- return nil169+ if favorites[i] == nil {
170+ return ""
171+ }
172+ return favorites[i].Title
173+ },
174+ )
176 }175 }
177 176
178 // findHistorySuggestion finds the best matching history entry for the input.177 // findHistorySuggestion finds the best matching history entry for the input.
@@ -189,22 +188,21 @@ func (uc *AutocompleteUseCase) findHistorySuggestion(ctx context.Context, input
189 return nil188 return nil
190 }189 }
191 190
192- for _, entry := range entries {191+ return bestSuggestionFromURLs(input, autocomplete.SourceHistory,
193- if entry == nil || entry.URL == "" {192+ len(entries),
194- continue193+ func(i int) string {
195- }194+ if entries[i] == nil {
196- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, entry.URL)195+ return ""
197- if ok {
198- return &autocomplete.Suggestion{
199- FullText: matchedURL,
200- Suffix: suffix,
201- Source: autocomplete.SourceHistory,
202- Title: entry.Title,
203 }196 }
204- }197+ return entries[i].URL
205- }198+ },
206-199+ func(i int) string {
207- return nil200+ if entries[i] == nil {
201+ return ""
202+ }
203+ return entries[i].Title
204+ },
205+ )
208 }206 }
209 207
210 // findBangSuggestion finds the best matching bang shortcut for the input.208 // findBangSuggestion finds the best matching bang shortcut for the input.
@@ -239,21 +237,56 @@ func (uc *AutocompleteUseCase) findBangSuggestion(ctx context.Context, input str
239 }237 }
240 238
241 func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {239 func findCompletionInURLs(_ context.Context, input string, urls []string, source autocomplete.SuggestionSource) *autocomplete.Suggestion {
242- for _, u := range urls {240+ suffix, matchedURL, ok := autocomplete.BestURLCompletion(input, urls)
243- if u == "" {241+ if ok {
244- continue242+ return &autocomplete.Suggestion{
243+ FullText: matchedURL,
244+ Suffix: suffix,
245+ Source: source,
245 }246 }
246- suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(input, u)247+ }
247- if ok {248+ return nil
248- return &autocomplete.Suggestion{249+}
249- FullText: matchedURL,250+
250- Suffix: suffix,251+func bestSuggestionFromURLs(
251- Source: source,252+ input string,
252- }253+ source autocomplete.SuggestionSource,
254+ count int,
255+ urlAt func(int) string,
256+ titleAt func(int) string,
257+) *autocomplete.Suggestion {
258+ if count <= 0 {
259+ return nil
260+ }
261+
262+ urls := make([]string, 0, count)
263+ titles := make([]string, 0, count)
264+ for i := 0; i < count; i++ {
265+ url := urlAt(i)
266+ if url == "" {
267+ continue
253 }268 }
269+ urls = append(urls, url)
270+ titles = append(titles, titleAt(i))
271+ }
272+ if len(urls) == 0 {
273+ return nil
254 }274 }
255 275
256- return nil276+ suffix, matchedURL, idx, ok := autocomplete.BestURLCompletionWithIndex(input, urls)
277+ if !ok {
278+ return nil
279+ }
280+ title := ""
281+ if idx >= 0 && idx < len(titles) {
282+ title = titles[idx]
283+ }
284+ return &autocomplete.Suggestion{
285+ FullText: matchedURL,
286+ Suffix: suffix,
287+ Source: source,
288+ Title: title,
289+ }
257 }290 }
258 291
259 func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {292 func (uc *AutocompleteUseCase) getCachedFavorites(ctx context.Context) ([]*entity.Favorite, error) {
M internal/application/usecase/autocomplete_test.go +21 -2
@@ -55,8 +55,8 @@ func TestAutocompleteUseCase_GetSuggestion_FavoritesPriority(t *testing.T) {
55 assert.True(t, output.Found)55 assert.True(t, output.Found)
56 assert.NotNil(t, output.Suggestion)56 assert.NotNil(t, output.Suggestion)
57 assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)57 assert.Equal(t, autocomplete.SourceFavorite, output.Suggestion.Source)
58- assert.Equal(t, "github.com/user/repo", output.Suggestion.FullText)58+ assert.Equal(t, "github.com", output.Suggestion.FullText)
59- assert.Equal(t, ".com/user/repo", output.Suggestion.Suffix)59+ assert.Equal(t, ".com", output.Suggestion.Suffix)
60 assert.Equal(t, "My Repo", output.Suggestion.Title)60 assert.Equal(t, "My Repo", output.Suggestion.Title)
61 }61 }
62 62
@@ -167,6 +167,25 @@ func TestAutocompleteUseCase_ResolveCompletion_VisibleURLsOrder(t *testing.T) {
167 assert.Equal(t, "a.dev", suggestion.Suffix)167 assert.Equal(t, "a.dev", suggestion.Suffix)
168 }168 }
169 169
170+func TestAutocompleteUseCase_ResolveCompletion_PrefersShortHostForGoo(t *testing.T) {
171+ ctx := testContext()
172+ uc := usecase.NewAutocompleteUseCase(nil, nil, nil)
173+
174+ urls := []string{
175+ "https://www.google.com/url?q=https://dashboard.stripe.com",
176+ "https://google.com",
177+ }
178+
179+ suggestion := uc.ResolveCompletion(ctx, "goo", usecase.CompletionOptions{
180+ VisibleURLs: urls,
181+ AllowBangs: false,
182+ })
183+
184+ require.NotNil(t, suggestion)
185+ assert.Equal(t, "google.com", suggestion.FullText)
186+ assert.Equal(t, "gle.com", suggestion.Suffix)
187+}
188+
170 func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {189 func TestAutocompleteUseCase_GetSuggestion_FavoritesCached(t *testing.T) {
171 ctx := testContext()190 ctx := testContext()
172 191
M internal/application/usecase/check_update.go +10 -0
@@ -2,6 +2,7 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 6
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/domain/build"8 "github.com/bnema/dumber/internal/domain/build"
@@ -53,6 +54,15 @@ func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) (
53 54
54 info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version)55 info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version)
55 if err != nil {56 if err != nil {
57+ if errors.Is(err, port.ErrUpdateCheckTransient) {
58+ log.Debug().Err(err).Msg("transient update check failure")
59+ return &CheckUpdateOutput{
60+ UpdateAvailable: false,
61+ CanAutoUpdate: false,
62+ CurrentVersion: uc.buildInfo.Version,
63+ LatestVersion: uc.buildInfo.Version,
64+ }, nil
65+ }
56 log.Warn().Err(err).Msg("update check failed")66 log.Warn().Err(err).Msg("update check failed")
57 return nil, err67 return nil, err
58 }68 }
A internal/application/usecase/check_update_test.go +88 -0
@@ -0,0 +1,88 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/build"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+func TestCheckUpdateUseCase_TransientErrorReturnsNoUpdate(t *testing.T) {
14+ uc := NewCheckUpdateUseCase(
15+ stubUpdateChecker{err: port.ErrUpdateCheckTransient},
16+ stubUpdateApplier{},
17+ build.Info{Version: "1.2.3"},
18+ )
19+
20+ out, err := uc.Execute(context.Background(), CheckUpdateInput{})
21+ if err != nil {
22+ t.Fatalf("Execute() unexpected error: %v", err)
23+ }
24+ if out.UpdateAvailable {
25+ t.Fatal("UpdateAvailable = true, want false")
26+ }
27+ if out.CurrentVersion != "1.2.3" || out.LatestVersion != "1.2.3" {
28+ t.Fatalf("unexpected versions: current=%q latest=%q", out.CurrentVersion, out.LatestVersion)
29+ }
30+}
31+
32+func TestCheckUpdateUseCase_NonTransientErrorPropagates(t *testing.T) {
33+ uc := NewCheckUpdateUseCase(
34+ stubUpdateChecker{err: errors.New("boom")},
35+ stubUpdateApplier{},
36+ build.Info{Version: "1.2.3"},
37+ )
38+
39+ _, err := uc.Execute(context.Background(), CheckUpdateInput{})
40+ if err == nil {
41+ t.Fatal("expected error, got nil")
42+ }
43+ if err.Error() != "boom" {
44+ t.Fatalf("unexpected error: %v", err)
45+ }
46+}
47+
48+type stubUpdateChecker struct {
49+ info *entity.UpdateInfo
50+ err error
51+}
52+
53+func (s stubUpdateChecker) CheckForUpdate(context.Context, string) (*entity.UpdateInfo, error) {
54+ if s.err != nil {
55+ return nil, s.err
56+ }
57+ return s.info, nil
58+}
59+
60+type stubUpdateApplier struct{}
61+
62+func (stubUpdateApplier) CanSelfUpdate(context.Context) bool {
63+ return false
64+}
65+
66+func (stubUpdateApplier) SelfUpdateBlockedReason(context.Context) port.SelfUpdateBlockedReason {
67+ return port.SelfUpdateBlockedNotWritable
68+}
69+
70+func (stubUpdateApplier) GetBinaryPath() (string, error) {
71+ return "", nil
72+}
73+
74+func (stubUpdateApplier) StageUpdate(context.Context, string) error {
75+ return nil
76+}
77+
78+func (stubUpdateApplier) HasStagedUpdate(context.Context) bool {
79+ return false
80+}
81+
82+func (stubUpdateApplier) ApplyOnExit(context.Context) (string, error) {
83+ return "", nil
84+}
85+
86+func (stubUpdateApplier) ClearStagedUpdate(context.Context) error {
87+ return nil
88+}
M internal/application/usecase/find_in_page.go +1 -1
@@ -43,7 +43,7 @@ type FindInPageUseCase struct {
43 ctx context.Context43 ctx context.Context
44 44
45 controller port.FindController45 controller port.FindController
46- signalIDs []uint3246+ signalIDs []uint
47 47
48 query string48 query string
49 matchCount uint49 matchCount uint
M internal/application/usecase/navigate.go +233 -52
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "net/url"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "time"9 "time"
@@ -20,12 +21,32 @@ const (
20 21
21 // logURLMaxLen is the max length for URLs in log messages.22 // logURLMaxLen is the max length for URLs in log messages.
22 logURLMaxLen = 6023 logURLMaxLen = 60
24+
25+ // historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26+ historyWorkerFlushInterval = 100 * time.Millisecond
27+
28+ // historyFallbackIncrementCap bounds DB writes when delta-increment API is unavailable.
29+ historyFallbackIncrementCap = 256
23 )30 )
24 31
25 // historyRecord holds data for async history recording.32 // historyRecord holds data for async history recording.
26 type historyRecord struct {33 type historyRecord struct {
27- url string34+ url string
28- timestamp time.Time35+ visits int
36+}
37+
38+type historyVisitDeltaIncrementer interface {
39+ IncrementVisitCountBy(ctx context.Context, url string, delta int) error
40+}
41+
42+type paneHistoryState struct {
43+ lastRawURL string
44+ lastCanonicalURL string
45+ lastRecordedAt time.Time
46+}
47+
48+type historyCanonicalizationOptions struct {
49+ StripTrackingParams bool
29 }50 }
30 51
31 // NavigateUseCase handles URL navigation with history recording and zoom application.52 // NavigateUseCase handles URL navigation with history recording and zoom application.
@@ -33,7 +54,9 @@ type NavigateUseCase struct {
33 historyRepo repository.HistoryRepository54 historyRepo repository.HistoryRepository
34 zoomRepo repository.ZoomRepository55 zoomRepo repository.ZoomRepository
35 defaultZoom float6456 defaultZoom float64
36- recentVisits sync.Map // key: url, value: time.Time - for deduplication57+ recentMu sync.Mutex
58+ recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
59+ closeOnce sync.Once
37 60
38 // Async history recording61 // Async history recording
39 historyQueue chan historyRecord62 historyQueue chan historyRecord
@@ -57,6 +80,7 @@ func NewNavigateUseCase(
57 historyRepo: historyRepo,80 historyRepo: historyRepo,
58 zoomRepo: zoomRepo,81 zoomRepo: zoomRepo,
59 defaultZoom: defaultZoom,82 defaultZoom: defaultZoom,
83+ recentVisits: make(map[string]paneHistoryState),
60 historyQueue: make(chan historyRecord, historyQueueSize),84 historyQueue: make(chan historyRecord, historyQueueSize),
61 done: make(chan struct{}),85 done: make(chan struct{}),
62 ctx: context.Background(),86 ctx: context.Background(),
@@ -72,7 +96,9 @@ func NewNavigateUseCase(
72 // Close shuts down the background history worker and drains any pending records.96 // Close shuts down the background history worker and drains any pending records.
73 // This should be called when the application is shutting down.97 // This should be called when the application is shutting down.
74 func (uc *NavigateUseCase) Close() {98 func (uc *NavigateUseCase) Close() {
75- close(uc.done)99+ uc.closeOnce.Do(func() {
100+ close(uc.done)
101+ })
76 uc.wg.Wait()102 uc.wg.Wait()
77 }103 }
78 104
@@ -117,36 +143,74 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
117 // This prevents inflation from redirects and rapid navigation.143 // This prevents inflation from redirects and rapid navigation.
118 const historyDeduplicationWindow = 2 * time.Second144 const historyDeduplicationWindow = 2 * time.Second
119 145
146+// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
147+// are removed while canonicalizing URLs for history deduplication/storage.
148+const stripTrackingParamsForHistoryDedup = true
149+
120 // RecordHistory queues a history entry for async recording.150 // RecordHistory queues a history entry for async recording.
121 // This is non-blocking to avoid SQLite I/O on the GTK main thread.151 // This is non-blocking to avoid SQLite I/O on the GTK main thread.
122 // Should be called on LoadCommitted when URI is guaranteed correct.152 // Should be called on LoadCommitted when URI is guaranteed correct.
123-func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {153+func (uc *NavigateUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
124 log := logging.FromContext(ctx)154 log := logging.FromContext(ctx)
155+ rawURL = strings.TrimSpace(rawURL)
156+ if rawURL == "" {
157+ return
158+ }
125 159
126- // Normalize URL to avoid duplicates (e.g., github.com vs github.com/)160+ canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
127- url = normalizeURLForHistory(url)161+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
162+ })
163+ if canonicalURL == "" {
164+ return
165+ }
166+
167+ paneID = normalizePaneID(paneID)
128 168
129- // Time-based deduplication: skip if same URL was recorded recently
130- // This check is fast (sync.Map lookup) and stays on the main thread
131 now := time.Now()169 now := time.Now()
132- if lastTime, ok := uc.recentVisits.Load(url); ok {170+
133- if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {171+ uc.recentMu.Lock()
134- log.Debug().Str("url", url).Msg("skipping duplicate history record within dedup window")172+ state := uc.recentVisits[paneID]
135- return173+
136- }174+ // Ignore in-page hash transitions for history persistence.
175+ if isHashOnlyTransition(state.lastRawURL, rawURL) {
176+ state.lastRawURL = rawURL
177+ uc.recentVisits[paneID] = state
178+ uc.recentMu.Unlock()
179+ return
137 }180 }
138- uc.recentVisits.Store(url, now)181+
182+ // Per-pane short-window deduplication by canonical URL.
183+ if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
184+ state.lastRawURL = rawURL
185+ uc.recentVisits[paneID] = state
186+ uc.recentMu.Unlock()
187+ return
188+ }
189+
190+ state.lastRawURL = rawURL
191+ state.lastCanonicalURL = canonicalURL
192+ state.lastRecordedAt = now
193+ uc.recentVisits[paneID] = state
194+ uc.recentMu.Unlock()
139 195
140 // Non-blocking send to async queue196 // Non-blocking send to async queue
141 select {197 select {
142- case uc.historyQueue <- historyRecord{url: url, timestamp: now}:198+ case uc.historyQueue <- historyRecord{url: canonicalURL, visits: 1}:
143- log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Msg("history record queued")
144 default:199 default:
145 // Queue full - log warning but don't block navigation200 // Queue full - log warning but don't block navigation
146- log.Warn().Str("url", url).Msg("history queue full, dropping record")201+ log.Warn().Str("url", logging.TruncateURL(canonicalURL, logURLMaxLen)).Msg("history queue full, dropping record")
147 }202 }
148 }203 }
149 204
205+// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
206+// Callers should invoke this when a pane is closed.
207+func (uc *NavigateUseCase) ClearPaneHistory(paneID string) {
208+ paneID = normalizePaneID(paneID)
209+ uc.recentMu.Lock()
210+ delete(uc.recentVisits, paneID)
211+ uc.recentMu.Unlock()
212+}
213+
150 // historyWorker is a background goroutine that processes history records.214 // historyWorker is a background goroutine that processes history records.
151 // It drains the queue and persists records to the database without blocking the UI.215 // It drains the queue and persists records to the database without blocking the UI.
152 func (uc *NavigateUseCase) historyWorker() {216 func (uc *NavigateUseCase) historyWorker() {
@@ -156,22 +220,44 @@ func (uc *NavigateUseCase) historyWorker() {
156 Str("component", "history-worker").220 Str("component", "history-worker").
157 Logger()221 Logger()
158 222
223+ ticker := time.NewTicker(historyWorkerFlushInterval)
224+ defer ticker.Stop()
225+
226+ pending := make(map[string]int)
227+
228+ flushPending := func() {
229+ if len(pending) == 0 {
230+ return
231+ }
232+ for historyURL, visits := range pending {
233+ uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
234+ }
235+ clear(pending)
236+ }
237+
238+ drainQueue := func() {
239+ for {
240+ select {
241+ case record := <-uc.historyQueue:
242+ pending[record.url] += record.visits
243+ default:
244+ return
245+ }
246+ }
247+ }
248+
159 for {249 for {
160 select {250 select {
161 case record := <-uc.historyQueue:251 case record := <-uc.historyQueue:
162- uc.persistHistory(uc.ctx, record)252+ pending[record.url] += record.visits
253+ case <-ticker.C:
254+ flushPending()
163 case <-uc.done:255 case <-uc.done:
164- // Drain remaining records before shutdown
165 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")256 log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
166- for {257+ drainQueue()
167- select {258+ flushPending()
168- case record := <-uc.historyQueue:259+ log.Debug().Msg("history worker shutdown complete")
169- uc.persistHistory(uc.ctx, record)260+ return
170- default:
171- log.Debug().Msg("history worker shutdown complete")
172- return
173- }
174- }
175 }261 }
176 }262 }
177 }263 }
@@ -189,13 +275,33 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
189 }275 }
190 276
191 if existing != nil {277 if existing != nil {
192- // Increment visit count278+ delta := max(1, record.visits)
193- if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {279+ if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
194- log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")280+ if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
281+ log.Warn().Err(err).Str("url", record.url).Int("delta", delta).Msg("failed to increment visit count by delta")
282+ return
283+ }
284+ return
285+ }
286+ iterations := delta
287+ if iterations > historyFallbackIncrementCap {
288+ log.Warn().
289+ Str("url", record.url).
290+ Int("delta", delta).
291+ Int("cap", historyFallbackIncrementCap).
292+ Msg("visit count fallback increment capped")
293+ iterations = historyFallbackIncrementCap
294+ }
295+ for i := 0; i < iterations; i++ {
296+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
297+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
298+ return
299+ }
195 }300 }
196 } else {301 } else {
197 // Create new entry302 // Create new entry
198 entry := entity.NewHistoryEntry(record.url, "")303 entry := entity.NewHistoryEntry(record.url, "")
304+ entry.VisitCount = int64(max(1, record.visits))
199 if err := uc.historyRepo.Save(ctx, entry); err != nil {305 if err := uc.historyRepo.Save(ctx, entry); err != nil {
200 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")306 log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
201 }307 }
@@ -203,14 +309,20 @@ func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRec
203 }309 }
204 310
205 // UpdateHistoryTitle updates the title of a history entry after page load.311 // UpdateHistoryTitle updates the title of a history entry after page load.
206-func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title string) error {312+func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, historyURL, title string) error {
207 log := logging.FromContext(ctx)313 log := logging.FromContext(ctx)
208 314
209- // Normalize URL to match storage (e.g., github.com/ -> github.com)315+ // Canonicalize URL the same way history records are persisted.
210- url = normalizeURLForHistory(url)316+ historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
211- log.Debug().Str("url", logging.TruncateURL(url, logURLMaxLen)).Str("title", title).Msg("updating history title")317+ StripTrackingParams: stripTrackingParamsForHistoryDedup,
318+ })
319+ if historyURL == "" {
320+ log.Debug().Str("title", title).Msg("history URL empty after canonicalization, skipping title update")
321+ return nil
322+ }
323+ log.Debug().Str("url", logging.TruncateURL(historyURL, logURLMaxLen)).Str("title", title).Msg("updating history title")
212 324
213- entry, err := uc.historyRepo.FindByURL(ctx, url)325+ entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
214 if err != nil {326 if err != nil {
215 return fmt.Errorf("failed to find history entry: %w", err)327 return fmt.Errorf("failed to find history entry: %w", err)
216 }328 }
@@ -219,7 +331,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
219 // Entry doesn't exist - don't create it here331 // Entry doesn't exist - don't create it here
220 // Initial navigation should have already created the entry332 // Initial navigation should have already created the entry
221 // This can happen if URL changed during page load (SPA, redirect)333 // This can happen if URL changed during page load (SPA, redirect)
222- log.Debug().Str("url", url).Msg("no history entry found for URL, skipping title update")334+ log.Debug().Str("url", historyURL).Msg("no history entry found for URL, skipping title update")
223 return nil335 return nil
224 }336 }
225 337
@@ -250,20 +362,89 @@ func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error
250 return webview.Stop(ctx)362 return webview.Stop(ctx)
251 }363 }
252 364
253-// normalizeURLForHistory normalizes a URL for history storage.365+func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
254-// Strips trailing slash to avoid duplicates like github.com vs github.com/366+ raw = strings.TrimSpace(raw)
255-func normalizeURLForHistory(url string) string {367+ if raw == "" {
256- // Don't strip if URL is just the protocol + domain (e.g., "https://github.com")368+ return ""
257- // Only strip trailing slash from paths369+ }
258- if strings.HasSuffix(url, "/") {370+
259- // Check if it's just domain with trailing slash (e.g., "https://github.com/")371+ parsed, err := url.Parse(raw)
260- // by counting slashes - protocol has 2, domain adds 0, path adds more372+ if err != nil {
261- if strings.Count(url, "/") == 3 {373+ return strings.TrimSuffix(raw, "/")
262- // It's "https://domain.com/" - strip the trailing slash374+ }
263- return strings.TrimSuffix(url, "/")375+
376+ parsed.Scheme = strings.ToLower(parsed.Scheme)
377+ parsed.Host = strings.ToLower(parsed.Host)
378+ parsed.Fragment = ""
379+ parsed.Path = normalizePathForHistory(parsed.Path)
380+
381+ query := parsed.Query()
382+ if opts.StripTrackingParams {
383+ for key := range query {
384+ if isTrackingQueryParam(key) {
385+ query.Del(key)
386+ }
264 }387 }
265- // For paths like "https://github.com/user/" - also strip
266- return strings.TrimSuffix(url, "/")
267 }388 }
268- return url389+ parsed.RawQuery = query.Encode()
390+
391+ return parsed.String()
392+}
393+
394+func normalizePathForHistory(path string) string {
395+ if path == "/" {
396+ return ""
397+ }
398+ if strings.HasSuffix(path, "/") {
399+ return strings.TrimSuffix(path, "/")
400+ }
401+ return path
402+}
403+
404+func isTrackingQueryParam(key string) bool {
405+ key = strings.ToLower(strings.TrimSpace(key))
406+ if key == "" {
407+ return false
408+ }
409+ if strings.HasPrefix(key, "utm_") {
410+ return true
411+ }
412+ switch key {
413+ case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
414+ return true
415+ }
416+ return false
417+}
418+
419+func isHashOnlyTransition(previous, current string) bool {
420+ if previous == "" || current == "" || previous == current {
421+ return false
422+ }
423+
424+ prevParsed, prevErr := url.Parse(previous)
425+ currParsed, currErr := url.Parse(current)
426+ if prevErr != nil || currErr != nil {
427+ return false
428+ }
429+
430+ if !strings.EqualFold(prevParsed.Scheme, currParsed.Scheme) {
431+ return false
432+ }
433+ if !strings.EqualFold(prevParsed.Host, currParsed.Host) {
434+ return false
435+ }
436+ if normalizePathForHistory(prevParsed.Path) != normalizePathForHistory(currParsed.Path) {
437+ return false
438+ }
439+ if prevParsed.RawQuery != currParsed.RawQuery {
440+ return false
441+ }
442+ return prevParsed.Fragment != currParsed.Fragment
443+}
444+
445+func normalizePaneID(paneID string) string {
446+ if strings.TrimSpace(paneID) == "" {
447+ return "__default__"
448+ }
449+ return paneID
269 }450 }
A internal/application/usecase/navigate_test.go +60 -0
@@ -0,0 +1,60 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
14+ got := canonicalizeURLForHistory(
15+ "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
16+ historyCanonicalizationOptions{StripTrackingParams: true},
17+ )
18+
19+ require.Equal(t, "https://example.com/path?a=2&b=1", got)
20+}
21+
22+func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
23+ got := canonicalizeURLForHistory(
24+ "https://example.com/path/?utm_source=newsletter&b=1#section-1",
25+ historyCanonicalizationOptions{StripTrackingParams: false},
26+ )
27+
28+ require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
29+}
30+
31+func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {
32+ ctx := context.Background()
33+ repo := repomocks.NewMockHistoryRepository(t)
34+
35+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
36+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
37+ return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
38+ })).Return(nil).Once()
39+
40+ uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
41+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
42+ uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
43+ uc.Close()
44+}
45+
46+func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
47+ ctx := context.Background()
48+ repo := repomocks.NewMockHistoryRepository(t)
49+
50+ repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
51+ repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
52+ return entry.URL == "https://example.com/article" && entry.VisitCount == 2
53+ })).Return(nil).Once()
54+
55+ uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
56+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
57+ uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
58+ uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
59+ uc.Close()
60+}
M internal/bootstrap/session.go +247 -0
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "sync"10 "sync"
10 "syscall"11 "syscall"
11 "time"12 "time"
@@ -24,6 +25,7 @@ const (
24 recentSessionsLimit = 20025 recentSessionsLimit = 200
25 lockDirPerm = 0o75526 lockDirPerm = 0o755
26 lockFilePerm = 0o60027 lockFilePerm = 0o600
28+ markerFilePerm = 0o644
27 )29 )
28 30
29 type BrowserSession struct {31 type BrowserSession struct {
@@ -95,6 +97,19 @@ func StartBrowserSession(
95 // error handling during shutdown sequences where persistFn may be called multiple times.97 // error handling during shutdown sequences where persistFn may be called multiple times.
96 persistFn := func(persistCtx context.Context) error {98 persistFn := func(persistCtx context.Context) error {
97 persistMu.Do(func() {99 persistMu.Do(func() {
100+ if lockDir != "" {
101+ abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger)
102+ if abruptErr != nil {
103+ logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
104+ } else {
105+ for _, abruptSessionID := range abruptSessions {
106+ logger.Warn().
107+ Str("session_id", abruptSessionID).
108+ Msg("abrupt-exit marker detected (startup without shutdown)")
109+ }
110+ }
111+ }
112+
98 saveCtx := corelogging.WithContext(context.Background(), logger)113 saveCtx := corelogging.WithContext(context.Background(), logger)
99 if err := sessionRepo.Save(saveCtx, session); err != nil {114 if err := sessionRepo.Save(saveCtx, session); err != nil {
100 persistErr = fmt.Errorf("save session: %w", err)115 persistErr = fmt.Errorf("save session: %w", err)
@@ -105,6 +120,11 @@ func StartBrowserSession(
105 Str("session_id", string(session.ID)).120 Str("session_id", string(session.ID)).
106 Str("type", string(session.Type)).121 Str("type", string(session.Type)).
107 Msg("session started")122 Msg("session started")
123+ if lockDir != "" {
124+ if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
125+ logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
126+ }
127+ }
108 128
109 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)129 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110 if lockErr != nil {130 if lockErr != nil {
@@ -133,6 +153,13 @@ func StartBrowserSession(
133 lp := lockPath153 lp := lockPath
134 lockMu.Unlock()154 lockMu.Unlock()
135 155
156+ if lockDir != "" {
157+ shutdownAt := time.Now().UTC()
158+ if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
159+ logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
160+ }
161+ }
162+
136 if lf != nil {163 if lf != nil {
137 _ = unlockAndClose(lf)164 _ = unlockAndClose(lf)
138 _ = os.Remove(lp)165 _ = os.Remove(lp)
@@ -263,6 +290,158 @@ func unlockAndClose(f *os.File) error {
263 return f.Close()290 return f.Close()
264 }291 }
265 292
293+func startupMarkerPath(lockDir, sessionID string) string {
294+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
295+}
296+
297+func shutdownMarkerPath(lockDir, sessionID string) string {
298+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
299+}
300+
301+func abruptMarkerPath(lockDir, sessionID string) string {
302+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
303+}
304+
305+func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
306+ if lockDir == "" || sessionID == "" {
307+ return errors.New("startup marker requires lockDir and sessionID")
308+ }
309+ if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
310+ return err
311+ }
312+ content := []byte(startedAt.Format(time.RFC3339Nano) + "\n")
313+ if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
314+ return err
315+ }
316+ _ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
317+ _ = os.Remove(abruptMarkerPath(lockDir, sessionID))
318+ return nil
319+}
320+
321+func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
322+ if lockDir == "" || sessionID == "" {
323+ return errors.New("shutdown marker requires lockDir and sessionID")
324+ }
325+ if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
326+ return err
327+ }
328+
329+ // Preserve the startup time inside the shutdown marker so
330+ // ClassifySessionExitFromMarkers can still read it after the
331+ // startup marker file is removed.
332+ var startupLine string
333+ startupPath := startupMarkerPath(lockDir, sessionID)
334+ if raw, readErr := os.ReadFile(startupPath); readErr == nil {
335+ if t := strings.TrimSpace(string(raw)); t != "" {
336+ startupLine = "started_at=" + t + "\n"
337+ }
338+ }
339+
340+ content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine)
341+ if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
342+ return err
343+ }
344+ // Clean up the matching startup marker now that shutdown is recorded.
345+ // Errors are non-fatal: the marker will be swept later if removal fails.
346+ _ = os.Remove(startupPath)
347+ return nil
348+}
349+
350+func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {
351+ if lockDir == "" {
352+ return nil, nil
353+ }
354+ pattern := filepath.Join(lockDir, "session_*.startup.marker")
355+ startupMarkers, err := filepath.Glob(pattern)
356+ if err != nil {
357+ return nil, err
358+ }
359+
360+ abruptSessions := make([]string, 0, len(startupMarkers))
361+ for _, startupPath := range startupMarkers {
362+ base := filepath.Base(startupPath)
363+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
364+ continue
365+ }
366+ sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
367+ if sessionID == "" {
368+ continue
369+ }
370+
371+ lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
372+ f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
373+ if openErr != nil {
374+ if os.IsNotExist(openErr) {
375+ // No lock file: fall through to mark as abrupt.
376+ } else {
377+ if logger != nil {
378+ logger.Warn().
379+ Err(openErr).
380+ Str("session_id", sessionID).
381+ Str("path", lockPath).
382+ Msg("markAbruptExits: open lock file failed")
383+ }
384+ continue
385+ }
386+ } else {
387+ locked, lockErr := tryLockExclusiveNonBlocking(f)
388+ if lockErr != nil {
389+ _ = f.Close()
390+ if logger != nil {
391+ logger.Warn().
392+ Err(lockErr).
393+ Str("session_id", sessionID).
394+ Str("path", lockPath).
395+ Msg("markAbruptExits: lock probe failed")
396+ }
397+ continue
398+ }
399+ if !locked {
400+ // Lock is held by another process: session still active.
401+ _ = f.Close()
402+ continue
403+ }
404+ // Lock acquired: no process holds it, session is stale.
405+ _ = unlockAndClose(f)
406+ }
407+
408+ shutdownPath := shutdownMarkerPath(lockDir, sessionID)
409+ if _, err := os.Stat(shutdownPath); err == nil {
410+ continue
411+ } else if !os.IsNotExist(err) {
412+ if logger != nil {
413+ logger.Warn().
414+ Err(err).
415+ Str("session_id", sessionID).
416+ Str("path", shutdownPath).
417+ Msg("markAbruptExits: stat failed")
418+ }
419+ continue
420+ }
421+ abruptPath := abruptMarkerPath(lockDir, sessionID)
422+ if _, err := os.Stat(abruptPath); err == nil {
423+ continue
424+ } else if !os.IsNotExist(err) {
425+ if logger != nil {
426+ logger.Warn().
427+ Err(err).
428+ Str("session_id", sessionID).
429+ Str("path", abruptPath).
430+ Msg("markAbruptExits: stat failed")
431+ }
432+ continue
433+ }
434+
435+ payload := []byte(fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath))
436+ if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), payload, markerFilePerm); err != nil {
437+ return abruptSessions, err
438+ }
439+ abruptSessions = append(abruptSessions, sessionID)
440+ }
441+
442+ return abruptSessions, nil
443+}
444+
266 // runSessionCleanupAsync performs stale session cleanup and old session pruning445 // runSessionCleanupAsync performs stale session cleanup and old session pruning
267 // in a background goroutine. This avoids blocking startup for non-critical tasks.446 // in a background goroutine. This avoids blocking startup for non-critical tasks.
268 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.447 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.
@@ -306,5 +485,73 @@ func runSessionCleanupAsync(
306 log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")485 log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
307 }486 }
308 }487 }
488+
489+ // Sweep paired startup+shutdown markers that are no longer needed.
490+ if lockDir != "" {
491+ sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)
492+ }
309 }()493 }()
310 }494 }
495+
496+// sweepPairedMarkers removes marker files for sessions that have both a
497+// shutdown marker and a startup marker older than maxAgeDays. Both markers
498+// must exist and be aged past the cutoff before any files are removed.
499+// The associated abrupt marker (if present) is also cleaned up.
500+// Removal errors are non-fatal (logged and skipped).
501+func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) {
502+ if lockDir == "" {
503+ return
504+ }
505+ if maxAgeDays <= 0 {
506+ maxAgeDays = 7
507+ }
508+ cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
509+
510+ shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker"))
511+ if err != nil {
512+ if log != nil {
513+ log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed")
514+ }
515+ return
516+ }
517+
518+ swept := 0
519+ for _, shutdownPath := range shutdownMarkers {
520+ base := filepath.Base(shutdownPath)
521+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") {
522+ continue
523+ }
524+ sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker")
525+ if sessionID == "" {
526+ continue
527+ }
528+
529+ shutdownInfo, err := os.Stat(shutdownPath)
530+ if err != nil || shutdownInfo.ModTime().After(cutoff) {
531+ continue
532+ }
533+
534+ startupPath := startupMarkerPath(lockDir, sessionID)
535+ startupInfo, err := os.Stat(startupPath)
536+ if err != nil || startupInfo.ModTime().After(cutoff) {
537+ // Startup marker missing or too recent — skip this session.
538+ continue
539+ }
540+
541+ abruptPath := abruptMarkerPath(lockDir, sessionID)
542+
543+ // Remove the trio: shutdown, startup, abrupt (if present).
544+ for _, p := range []string{shutdownPath, startupPath, abruptPath} {
545+ if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {
546+ if log != nil {
547+ log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed")
548+ }
549+ }
550+ }
551+ swept++
552+ }
553+
554+ if swept > 0 && log != nil {
555+ log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files")
556+ }
557+}
A internal/bootstrap/session_exit_classification.go +209 -0
@@ -0,0 +1,209 @@
1+package bootstrap
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "sort"
8+ "strings"
9+ "time"
10+)
11+
12+type SessionExitClass string
13+
14+const (
15+ SessionExitCleanExit SessionExitClass = "clean_exit"
16+ SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt"
17+ SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred"
18+ SessionExitUnknown SessionExitClass = "unknown"
19+)
20+
21+type SessionExitClassification struct {
22+ SessionID string
23+ Class SessionExitClass
24+ Inference string
25+ Reason string
26+ StartupAt *time.Time
27+ ShutdownAt *time.Time
28+ AbruptDetectedAt *time.Time
29+ LastMarkerObserved time.Time
30+}
31+
32+func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
33+ classification := SessionExitClassification{
34+ SessionID: sessionID,
35+ Class: SessionExitUnknown,
36+ Inference: "marker-missing",
37+ Reason: "no known marker files found for session",
38+ }
39+ if lockDir == "" || sessionID == "" {
40+ return classification, fmt.Errorf("lockDir and sessionID are required")
41+ }
42+
43+ startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "")
44+ if err != nil {
45+ return classification, err
46+ }
47+ shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "")
48+ if err != nil {
49+ return classification, err
50+ }
51+ abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=")
52+ if err != nil {
53+ return classification, err
54+ }
55+
56+ // If the startup marker was already cleaned up (writeShutdownMarker removes
57+ // it), fall back to the started_at line embedded in the shutdown marker.
58+ if startupAt == nil && shutdownExists {
59+ startupAt, _, _, _ = readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "started_at=")
60+ }
61+ classification.StartupAt = startupAt
62+ classification.ShutdownAt = shutdownAt
63+ classification.AbruptDetectedAt = abruptAt
64+ classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
65+
66+ switch {
67+ case shutdownExists:
68+ classification.Class = SessionExitCleanExit
69+ classification.Inference = "marker-confirmed"
70+ classification.Reason = "shutdown marker present"
71+ case abruptExists:
72+ classification.Class = SessionExitMainProcessCrashOrAbrupt
73+ classification.Inference = "marker-confirmed"
74+ classification.Reason = "abrupt marker present and no shutdown marker"
75+ case startupExists:
76+ classification.Class = SessionExitExternalKillOrOOMInferred
77+ classification.Inference = "best-effort"
78+ classification.Reason = "startup marker present without shutdown/abrupt markers"
79+ }
80+
81+ return classification, nil
82+}
83+
84+func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
85+ if lockDir == "" {
86+ return nil, nil
87+ }
88+
89+ sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
90+ if err != nil {
91+ return nil, err
92+ }
93+ if len(sessionIDs) == 0 {
94+ return nil, nil
95+ }
96+
97+ report := make([]SessionExitClassification, 0, len(sessionIDs))
98+ for _, sessionID := range sessionIDs {
99+ classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
100+ if classErr != nil {
101+ return nil, classErr
102+ }
103+ report = append(report, classification)
104+ }
105+
106+ sort.Slice(report, func(i, j int) bool {
107+ return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
108+ })
109+ return report, nil
110+}
111+
112+func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
113+ pattern := filepath.Join(lockDir, "session_*.marker")
114+ paths, err := filepath.Glob(pattern)
115+ if err != nil {
116+ return nil, err
117+ }
118+
119+ ids := make(map[string]struct{}, len(paths))
120+ for _, path := range paths {
121+ base := filepath.Base(path)
122+ sessionID, ok := markerSessionID(base)
123+ if !ok {
124+ continue
125+ }
126+ ids[sessionID] = struct{}{}
127+ }
128+
129+ sessionIDs := make([]string, 0, len(ids))
130+ for id := range ids {
131+ sessionIDs = append(sessionIDs, id)
132+ }
133+ sort.Strings(sessionIDs)
134+ return sessionIDs, nil
135+}
136+
137+func markerSessionID(base string) (string, bool) {
138+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
139+ return "", false
140+ }
141+
142+ trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
143+ switch {
144+ case strings.HasSuffix(trimmed, ".startup"):
145+ return strings.TrimSuffix(trimmed, ".startup"), true
146+ case strings.HasSuffix(trimmed, ".shutdown"):
147+ return strings.TrimSuffix(trimmed, ".shutdown"), true
148+ case strings.HasSuffix(trimmed, ".abrupt"):
149+ return strings.TrimSuffix(trimmed, ".abrupt"), true
150+ default:
151+ return "", false
152+ }
153+}
154+
155+func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
156+ stat, err := os.Stat(path)
157+ if err != nil {
158+ if os.IsNotExist(err) {
159+ return nil, false, time.Time{}, nil
160+ }
161+ return nil, false, time.Time{}, err
162+ }
163+
164+ content, err := os.ReadFile(path)
165+ if err != nil {
166+ return nil, true, stat.ModTime(), err
167+ }
168+
169+ raw := strings.TrimSpace(string(content))
170+ if raw == "" {
171+ return nil, true, stat.ModTime(), nil
172+ }
173+
174+ if keyPrefix != "" {
175+ lines := strings.Split(raw, "\n")
176+ for _, line := range lines {
177+ if strings.HasPrefix(line, keyPrefix) {
178+ parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
179+ if parseErr != nil {
180+ return nil, true, stat.ModTime(), nil
181+ }
182+ return &parsed, true, stat.ModTime(), nil
183+ }
184+ }
185+ return nil, true, stat.ModTime(), nil
186+ }
187+
188+ // When no key prefix is given, parse the first line as the timestamp.
189+ // Multi-line markers may contain additional metadata (e.g. started_at=).
190+ firstLine, _, _ := strings.Cut(raw, "\n")
191+ parsed, parseErr := time.Parse(time.RFC3339Nano, firstLine)
192+ if parseErr != nil {
193+ return nil, true, stat.ModTime(), nil
194+ }
195+ return &parsed, true, stat.ModTime(), nil
196+}
197+
198+func latestNonZero(values ...time.Time) time.Time {
199+ var latest time.Time
200+ for _, value := range values {
201+ if value.IsZero() {
202+ continue
203+ }
204+ if latest.IsZero() || value.After(latest) {
205+ latest = value
206+ }
207+ }
208+ return latest
209+}
A internal/bootstrap/session_exit_classification_test.go +115 -0
@@ -0,0 +1,115 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) {
14+ lockDir := t.TempDir()
15+ sessionID := "clean-1"
16+ startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC)
17+ endedAt := startedAt.Add(2 * time.Minute)
18+
19+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
20+ require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt))
21+
22+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
23+ require.NoError(t, err)
24+ assert.Equal(t, SessionExitCleanExit, classification.Class)
25+ assert.Equal(t, "marker-confirmed", classification.Inference)
26+ assert.Equal(t, "shutdown marker present", classification.Reason)
27+ require.NotNil(t, classification.StartupAt)
28+ assert.Equal(t, startedAt, *classification.StartupAt)
29+ require.NotNil(t, classification.ShutdownAt)
30+ assert.Equal(t, endedAt, *classification.ShutdownAt)
31+ assert.Nil(t, classification.AbruptDetectedAt)
32+}
33+
34+func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) {
35+ lockDir := t.TempDir()
36+ sessionID := "abrupt-1"
37+ startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
38+ detectedAt := startedAt.Add(5 * time.Minute)
39+
40+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
41+ require.NoError(t, os.WriteFile(
42+ abruptMarkerPath(lockDir, sessionID),
43+ []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"),
44+ markerFilePerm,
45+ ))
46+
47+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
48+ require.NoError(t, err)
49+ assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class)
50+ assert.Equal(t, "marker-confirmed", classification.Inference)
51+ assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason)
52+ require.NotNil(t, classification.StartupAt)
53+ assert.Equal(t, startedAt, *classification.StartupAt)
54+ assert.Nil(t, classification.ShutdownAt)
55+ require.NotNil(t, classification.AbruptDetectedAt)
56+ assert.Equal(t, detectedAt, *classification.AbruptDetectedAt)
57+}
58+
59+func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) {
60+ lockDir := t.TempDir()
61+ sessionID := "inferred-1"
62+ startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
63+
64+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
65+
66+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
67+ require.NoError(t, err)
68+ assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class)
69+ assert.Equal(t, "best-effort", classification.Inference)
70+ assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason)
71+ require.NotNil(t, classification.StartupAt)
72+ assert.Equal(t, startedAt, *classification.StartupAt)
73+ assert.Nil(t, classification.ShutdownAt)
74+ assert.Nil(t, classification.AbruptDetectedAt)
75+}
76+
77+func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) {
78+ _, err := ClassifySessionExitFromMarkers("", "s1")
79+ require.Error(t, err)
80+
81+ _, err = ClassifySessionExitFromMarkers(t.TempDir(), "")
82+ require.Error(t, err)
83+}
84+
85+func TestBuildSessionExitReport(t *testing.T) {
86+ lockDir := t.TempDir()
87+
88+ require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC)))
89+ require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC)))
90+
91+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC)))
92+ require.NoError(t, os.WriteFile(
93+ abruptMarkerPath(lockDir, "abrupt-r"),
94+ []byte("detected_at=2026-02-07T08:08:00Z\n"),
95+ markerFilePerm,
96+ ))
97+
98+ require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC)))
99+
100+ // Unknown marker type should not be included.
101+ require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm))
102+
103+ report, err := BuildSessionExitReport(lockDir)
104+ require.NoError(t, err)
105+ require.Len(t, report, 3)
106+
107+ got := map[string]SessionExitClass{}
108+ for _, item := range report {
109+ got[item.SessionID] = item.Class
110+ }
111+
112+ assert.Equal(t, SessionExitCleanExit, got["clean-r"])
113+ assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"])
114+ assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"])
115+}
A internal/bootstrap/session_markers_test.go +65 -0
@@ -0,0 +1,65 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
14+ lockDir := t.TempDir()
15+ sessionID := "s123"
16+ startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
17+ endedAt := startedAt.Add(3 * time.Minute)
18+
19+ err := writeStartupMarker(lockDir, sessionID, startedAt)
20+ require.NoError(t, err)
21+
22+ startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
23+ require.NoError(t, err)
24+ assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))
25+
26+ err = writeShutdownMarker(lockDir, sessionID, endedAt)
27+ require.NoError(t, err)
28+
29+ shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
30+ require.NoError(t, err)
31+ assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
32+}
33+
34+func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
35+ lockDir := t.TempDir()
36+ detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
37+
38+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute)))
39+ require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
40+ require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
41+
42+ abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil)
43+ require.NoError(t, err)
44+ assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
45+
46+ abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
47+ require.NoError(t, err)
48+ assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
49+ assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
50+}
51+
52+func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
53+ lockDir := t.TempDir()
54+ detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
55+
56+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
57+
58+ first, err := markAbruptExits(lockDir, detectedAt, nil)
59+ require.NoError(t, err)
60+ assert.Equal(t, []string{"abrupt-c"}, first)
61+
62+ second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil)
63+ require.NoError(t, err)
64+ assert.Empty(t, second)
65+}
M internal/bootstrap/webkit_stack.go +21 -4
@@ -46,7 +46,11 @@ func BuildWebKitStack(input WebKitStackInput) WebKitStack {
46 configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)46 configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
47 logging.Trace().Mark("render_env")47 logging.Trace().Mark("render_env")
48 48
49- wkOpts := buildWebKitContextOptions(input.DataDir, input.CacheDir, &perfSettings)49+ wkOpts := buildWebKitContextOptions(cfg, input.DataDir, input.CacheDir, &perfSettings)
50+ logger.Info().
51+ Str("cookie_policy", string(wkOpts.CookiePolicy)).
52+ Bool("itp_enabled", wkOpts.ITPEnabled).
53+ Msg("webkit privacy configuration")
50 wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)54 wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
51 if err != nil {55 if err != nil {
52 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")56 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
@@ -97,10 +101,23 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo
97 return perfSettings101 return perfSettings
98 }102 }
99 103
100-func buildWebKitContextOptions(dataDir, cacheDir string, perfSettings *config.ResolvedPerformanceSettings) port.WebKitContextOptions {104+func buildWebKitContextOptions(
105+ cfg *config.Config,
106+ dataDir, cacheDir string,
107+ perfSettings *config.ResolvedPerformanceSettings,
108+) port.WebKitContextOptions {
109+ cookiePolicy := port.WebKitCookiePolicyNoThirdParty
110+ itpEnabled := true
111+ if cfg != nil {
112+ cookiePolicy = port.WebKitCookiePolicy(cfg.Privacy.CookiePolicy)
113+ itpEnabled = cfg.Privacy.ITPEnabled
114+ }
115+
101 wkOpts := port.WebKitContextOptions{116 wkOpts := port.WebKitContextOptions{
102- DataDir: dataDir,117+ DataDir: dataDir,
103- CacheDir: cacheDir,118+ CacheDir: cacheDir,
119+ CookiePolicy: cookiePolicy,
120+ ITPEnabled: itpEnabled,
104 }121 }
105 122
106 if hasWebProcessMemoryConfig(perfSettings) {123 if hasWebProcessMemoryConfig(perfSettings) {
A internal/bootstrap/webkit_stack_test.go +49 -0
@@ -0,0 +1,49 @@
1+package bootstrap
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) {
13+ cfg := config.DefaultConfig()
14+ cfg.Privacy.CookiePolicy = config.CookiePolicyNever
15+ cfg.Privacy.ITPEnabled = false
16+
17+ perf := &config.ResolvedPerformanceSettings{
18+ WebProcessMemoryLimitMB: 1024,
19+ WebProcessMemoryPollIntervalSec: 11,
20+ WebProcessMemoryConservativeThreshold: 0.25,
21+ WebProcessMemoryStrictThreshold: 0.5,
22+ NetworkProcessMemoryLimitMB: 512,
23+ NetworkProcessMemoryPollIntervalSec: 13,
24+ }
25+
26+ opts := buildWebKitContextOptions(cfg, "/tmp/data", "/tmp/cache", perf)
27+
28+ assert.Equal(t, "/tmp/data", opts.DataDir)
29+ assert.Equal(t, "/tmp/cache", opts.CacheDir)
30+ assert.Equal(t, port.WebKitCookiePolicyNever, opts.CookiePolicy)
31+ assert.False(t, opts.ITPEnabled)
32+
33+ require.NotNil(t, opts.WebProcessMemory)
34+ assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)
35+ assert.InDelta(t, 11.0, opts.WebProcessMemory.PollIntervalSec, 1e-9)
36+ assert.InDelta(t, 0.25, opts.WebProcessMemory.ConservativeThreshold, 1e-9)
37+ assert.InDelta(t, 0.5, opts.WebProcessMemory.StrictThreshold, 1e-9)
38+
39+ require.NotNil(t, opts.NetworkProcessMemory)
40+ assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)
41+ assert.InDelta(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec, 1e-9)
42+}
43+
44+func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
45+ opts := buildWebKitContextOptions(nil, "/tmp/data", "/tmp/cache", &config.ResolvedPerformanceSettings{})
46+
47+ assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
48+ assert.True(t, opts.ITPEnabled)
49+}
M internal/domain/autocomplete/autocomplete.go +123 -1
@@ -1,7 +1,11 @@
1 // Package autocomplete provides domain types and logic for URL autocompletion.1 // Package autocomplete provides domain types and logic for URL autocompletion.
2 package autocomplete2 package autocomplete
3 3
4-import "strings"4+import (
5+ stdurl "net/url"
6+ "strings"
7+ "unicode/utf8"
8+)
5 9
6 // SuggestionSource indicates the origin of an autocomplete suggestion.10 // SuggestionSource indicates the origin of an autocomplete suggestion.
7 type SuggestionSource int11 type SuggestionSource int
@@ -78,3 +82,121 @@ func ComputeURLCompletionSuffix(input, fullURL string) (suffix, matchedURL strin
78 82
79 return "", "", false83 return "", "", false
80 }84 }
85+
86+// BestURLCompletion returns the best completion candidate from a list of URLs.
87+// For host-like queries (for example "goo"), it prefers short host completions
88+// over deep redirect paths.
89+func BestURLCompletion(input string, urls []string) (suffix, matchedURL string, ok bool) {
90+ suffix, matchedURL, _, ok = BestURLCompletionWithIndex(input, urls)
91+ return suffix, matchedURL, ok
92+}
93+
94+// BestURLCompletionWithIndex returns the best completion candidate and its source URL index.
95+func BestURLCompletionWithIndex(input string, urls []string) (suffix, matchedURL string, index int, ok bool) {
96+ bestScore := completionScore{
97+ valid: false,
98+ }
99+ bestSuffix := ""
100+ bestMatched := ""
101+ bestIndex := -1
102+
103+ for idx, rawURL := range urls {
104+ if rawURL == "" {
105+ continue
106+ }
107+
108+ candidateText, candidateSuffix, candidateOK := completionCandidateForURL(input, rawURL)
109+ if !candidateOK {
110+ continue
111+ }
112+
113+ score := rankCompletion(candidateText, candidateSuffix, idx)
114+ if !bestScore.valid || score.less(bestScore) {
115+ bestScore = score
116+ bestSuffix = candidateSuffix
117+ bestMatched = candidateText
118+ bestIndex = idx
119+ }
120+ }
121+
122+ if !bestScore.valid {
123+ return "", "", -1, false
124+ }
125+ return bestSuffix, bestMatched, bestIndex, true
126+}
127+
128+func completionCandidateForURL(input, rawURL string) (matchedURL, suffix string, ok bool) {
129+ if looksLikeHostInput(input) {
130+ if host := hostForCompletion(rawURL); host != "" {
131+ for _, candidate := range []string{host, strings.TrimPrefix(host, "www.")} {
132+ if completionSuffix, completionOK := ComputeCompletionSuffix(input, candidate); completionOK {
133+ return candidate, completionSuffix, true
134+ }
135+ }
136+ }
137+ }
138+
139+ completionSuffix, completionMatched, completionOK := ComputeURLCompletionSuffix(input, rawURL)
140+ if !completionOK {
141+ return "", "", false
142+ }
143+ return completionMatched, completionSuffix, true
144+}
145+
146+func looksLikeHostInput(input string) bool {
147+ if input == "" || strings.HasPrefix(input, "!") {
148+ return false
149+ }
150+ return !strings.ContainsAny(input, "/?#=& ")
151+}
152+
153+func hostForCompletion(raw string) string {
154+ if raw == "" {
155+ return ""
156+ }
157+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
158+ return strings.ToLower(parsed.Hostname())
159+ }
160+
161+ trimmed := strings.ToLower(StripProtocol(raw))
162+ for _, sep := range []string{"/", "?", "#"} {
163+ if idx := strings.Index(trimmed, sep); idx >= 0 {
164+ trimmed = trimmed[:idx]
165+ }
166+ }
167+ return strings.TrimSpace(trimmed)
168+}
169+
170+type completionScore struct {
171+ valid bool
172+ hasPathOrArgs bool
173+ suffixLen int
174+ totalLen int
175+ index int
176+}
177+
178+func rankCompletion(matchedURL, suffix string, index int) completionScore {
179+ return completionScore{
180+ valid: true,
181+ hasPathOrArgs: strings.ContainsAny(matchedURL, "/?#"),
182+ suffixLen: utf8.RuneCountInString(suffix),
183+ totalLen: utf8.RuneCountInString(matchedURL),
184+ index: index,
185+ }
186+}
187+
188+func (s completionScore) less(other completionScore) bool {
189+ if s.hasPathOrArgs != other.hasPathOrArgs {
190+ return !s.hasPathOrArgs
191+ }
192+ if s.index != other.index {
193+ return s.index < other.index
194+ }
195+ if s.suffixLen != other.suffixLen {
196+ return s.suffixLen < other.suffixLen
197+ }
198+ if s.totalLen != other.totalLen {
199+ return s.totalLen < other.totalLen
200+ }
201+ return false
202+}
M internal/domain/autocomplete/autocomplete_test.go +53 -0
@@ -266,3 +266,56 @@ func TestComputeURLCompletionSuffix(t *testing.T) {
266 })266 })
267 }267 }
268 }268 }
269+
270+func TestBestURLCompletion_PrefersHostForHostLikeInput(t *testing.T) {
271+ urls := []string{
272+ "https://www.google.com/url?q=https://dashboard.stripe.com/auth",
273+ "https://google.com",
274+ }
275+
276+ suffix, matchedURL, ok := BestURLCompletion("goo", urls)
277+ if !ok {
278+ t.Fatalf("expected completion")
279+ }
280+ if matchedURL != "google.com" {
281+ t.Fatalf("expected host completion, got %q", matchedURL)
282+ }
283+ if suffix != "gle.com" {
284+ t.Fatalf("expected host suffix, got %q", suffix)
285+ }
286+}
287+
288+func TestBestURLCompletion_KeepsPathForPathLikeInput(t *testing.T) {
289+ urls := []string{
290+ "https://google.com/url?q=https://example.com",
291+ }
292+
293+ suffix, matchedURL, ok := BestURLCompletion("google.com/u", urls)
294+ if !ok {
295+ t.Fatalf("expected completion")
296+ }
297+ if matchedURL != "google.com/url?q=https://example.com" {
298+ t.Fatalf("unexpected matched URL: %q", matchedURL)
299+ }
300+ if suffix != "rl?q=https://example.com" {
301+ t.Fatalf("unexpected suffix: %q", suffix)
302+ }
303+}
304+
305+func TestBestURLCompletion_SelectsAnyValidPrefixFromVisibleList(t *testing.T) {
306+ urls := []string{
307+ "https://example.org",
308+ "https://google.com/maps",
309+ }
310+
311+ suffix, matchedURL, ok := BestURLCompletion("goo", urls)
312+ if !ok {
313+ t.Fatalf("expected completion from second URL")
314+ }
315+ if matchedURL != "google.com" {
316+ t.Fatalf("expected host completion from second URL, got %q", matchedURL)
317+ }
318+ if suffix != "gle.com" {
319+ t.Fatalf("unexpected suffix: %q", suffix)
320+ }
321+}
M internal/infrastructure/config/defaults.go +5 -1
@@ -153,6 +153,10 @@ func DefaultConfig() *Config {
153 SampleMemory: false,153 SampleMemory: false,
154 DebugFrames: false,154 DebugFrames: false,
155 },155 },
156+ Privacy: PrivacyConfig{
157+ CookiePolicy: CookiePolicyNoThirdParty,
158+ ITPEnabled: true,
159+ },
156 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability160 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
157 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%161 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
158 Workspace: WorkspaceConfig{162 Workspace: WorkspaceConfig{
@@ -289,7 +293,7 @@ func DefaultConfig() *Config {
289 Prefix: "",293 Prefix: "",
290 },294 },
291 Performance: PerformanceConfig{295 Performance: PerformanceConfig{
292- Profile: ProfileDefault, // Use WebKit defaults, no tuning296+ Profile: ProfileDefault, // Use WebKit defaults by default
293 ZoomCacheSize: defaultZoomCacheSize,297 ZoomCacheSize: defaultZoomCacheSize,
294 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,298 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
295 // Skia threading - balanced defaults (unset = use WebKit defaults)299 // Skia threading - balanced defaults (unset = use WebKit defaults)
A internal/infrastructure/config/defaults_test.go +17 -0
@@ -0,0 +1,17 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) {
10+ cfg := DefaultConfig()
11+ assert.Equal(t, "info", cfg.Logging.Level)
12+ assert.False(t, cfg.Logging.CaptureGTKLogs)
13+ assert.Equal(t, ProfileDefault, cfg.Performance.Profile)
14+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
15+ assert.True(t, cfg.Privacy.ITPEnabled)
16+ assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
17+}
M internal/infrastructure/config/loader.go +41 -6
@@ -177,6 +177,16 @@ func ensureDatabasePath(config *Config) error {
177 }177 }
178 178
179 func normalizeConfig(config *Config) {179 func normalizeConfig(config *Config) {
180+ normalizeRendering(config)
181+ normalizePrivacy(config)
182+ normalizeAppearance(config)
183+ normalizeMedia(config)
184+
185+ config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
186+ normalizePerformanceProfile(config)
187+}
188+
189+func normalizeRendering(config *Config) {
180 switch strings.ToLower(string(config.Rendering.Mode)) {190 switch strings.ToLower(string(config.Rendering.Mode)) {
181 case "", string(RenderingModeAuto):191 case "", string(RenderingModeAuto):
182 config.Rendering.Mode = RenderingModeAuto192 config.Rendering.Mode = RenderingModeAuto
@@ -200,15 +210,35 @@ func normalizeConfig(config *Config) {
200 default:210 default:
201 config.Rendering.GSKRenderer = GSKRendererAuto211 config.Rendering.GSKRenderer = GSKRendererAuto
202 }212 }
213+}
214+
215+func normalizePrivacy(config *Config) {
216+ switch strings.ToLower(string(config.Privacy.CookiePolicy)) {
217+ case "", string(CookiePolicyNoThirdParty):
218+ config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
219+ case string(CookiePolicyAlways):
220+ config.Privacy.CookiePolicy = CookiePolicyAlways
221+ case string(CookiePolicyNever):
222+ config.Privacy.CookiePolicy = CookiePolicyNever
223+ default:
224+ config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
225+ }
226+}
203 227
204- switch config.Appearance.ColorScheme {228+func normalizeAppearance(config *Config) {
205- case ThemePreferDark, ThemePreferLight, ThemeDefault:229+ switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
206- case "":230+ case strings.ToLower(ThemePreferDark):
231+ config.Appearance.ColorScheme = ThemePreferDark
232+ case strings.ToLower(ThemePreferLight):
233+ config.Appearance.ColorScheme = ThemePreferLight
234+ case strings.ToLower(ThemeDefault):
207 config.Appearance.ColorScheme = ThemeDefault235 config.Appearance.ColorScheme = ThemeDefault
208 default:236 default:
209 config.Appearance.ColorScheme = ThemeDefault237 config.Appearance.ColorScheme = ThemeDefault
210 }238 }
239+}
211 240
241+func normalizeMedia(config *Config) {
212 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {242 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
213 case "", string(HardwareDecodingAuto):243 case "", string(HardwareDecodingAuto):
214 config.Media.HardwareDecodingMode = HardwareDecodingAuto244 config.Media.HardwareDecodingMode = HardwareDecodingAuto
@@ -232,9 +262,6 @@ func normalizeConfig(config *Config) {
232 default:262 default:
233 config.Media.GLRenderingMode = GLRenderingModeAuto263 config.Media.GLRenderingMode = GLRenderingModeAuto
234 }264 }
235-
236- config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
237- normalizePerformanceProfile(config)
238 }265 }
239 266
240 func normalizePerformanceProfile(config *Config) {267 func normalizePerformanceProfile(config *Config) {
@@ -247,6 +274,8 @@ func normalizePerformanceProfile(config *Config) {
247 config.Performance.Profile = ProfileMax274 config.Performance.Profile = ProfileMax
248 case string(ProfileCustom):275 case string(ProfileCustom):
249 config.Performance.Profile = ProfileCustom276 config.Performance.Profile = ProfileCustom
277+ default:
278+ config.Performance.Profile = ProfileDefault
250 }279 }
251 }280 }
252 281
@@ -373,6 +402,7 @@ func (m *Manager) setDefaults() {
373 m.setDebugDefaults(defaults)402 m.setDebugDefaults(defaults)
374 m.setAppearanceDefaults(defaults)403 m.setAppearanceDefaults(defaults)
375 m.setRenderingDefaults(defaults)404 m.setRenderingDefaults(defaults)
405+ m.setPrivacyDefaults(defaults)
376 m.setWorkspaceDefaults(defaults)406 m.setWorkspaceDefaults(defaults)
377 m.setContentFilteringDefaults(defaults)407 m.setContentFilteringDefaults(defaults)
378 m.setClipboardDefaults(defaults)408 m.setClipboardDefaults(defaults)
@@ -447,6 +477,11 @@ func (m *Manager) setRenderingDefaults(defaults *Config) {
447 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)477 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
448 }478 }
449 479
480+func (m *Manager) setPrivacyDefaults(defaults *Config) {
481+ m.viper.SetDefault("privacy.cookie_policy", string(defaults.Privacy.CookiePolicy))
482+ m.viper.SetDefault("privacy.itp_enabled", defaults.Privacy.ITPEnabled)
483+}
484+
450 func (m *Manager) setWorkspaceDefaults(defaults *Config) {485 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
451 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)486 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
452 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)487 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
A internal/infrastructure/config/loader_test.go +25 -0
@@ -0,0 +1,25 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestSetPrivacyDefaults(t *testing.T) {
11+ mgr := &Manager{viper: viper.New()}
12+ mgr.setDefaults()
13+
14+ assert.Equal(t, "no_third_party", mgr.viper.GetString("privacy.cookie_policy"))
15+ assert.True(t, mgr.viper.GetBool("privacy.itp_enabled"))
16+}
17+
18+func TestNormalizeConfig_PrivacyCookiePolicy(t *testing.T) {
19+ cfg := DefaultConfig()
20+ cfg.Privacy.CookiePolicy = CookiePolicy("INVALID")
21+
22+ normalizeConfig(cfg)
23+
24+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
25+}
M internal/infrastructure/config/schema.go +23 -0
@@ -27,6 +27,8 @@ type Config struct {
27 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`27 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
28 // Rendering controls WebKit, GTK and compositor behavior.28 // Rendering controls WebKit, GTK and compositor behavior.
29 Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"`29 Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"`
30+ // Privacy controls cookie acceptance and tracking prevention.
31+ Privacy PrivacyConfig `mapstructure:"privacy" yaml:"privacy" toml:"privacy"`
30 // Media controls video playback and hardware acceleration32 // Media controls video playback and hardware acceleration
31 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`33 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
32 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).34 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
@@ -48,6 +50,18 @@ const (
48 RenderingModeCPU RenderingMode = "cpu"50 RenderingModeCPU RenderingMode = "cpu"
49 )51 )
50 52
53+// CookiePolicy controls cookie acceptance behavior.
54+type CookiePolicy string
55+
56+const (
57+ // CookiePolicyAlways accepts all cookies.
58+ CookiePolicyAlways CookiePolicy = "always"
59+ // CookiePolicyNoThirdParty blocks third-party cookies.
60+ CookiePolicyNoThirdParty CookiePolicy = "no_third_party"
61+ // CookiePolicyNever blocks all cookies.
62+ CookiePolicyNever CookiePolicy = "never"
63+)
64+
51 // GSKRendererMode controls the GTK Scene Kit renderer selection.65 // GSKRendererMode controls the GTK Scene Kit renderer selection.
52 type GSKRendererMode string66 type GSKRendererMode string
53 67
@@ -103,6 +117,15 @@ type RenderingConfig struct {
103 DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`117 DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`
104 }118 }
105 119
120+// PrivacyConfig controls cookie behavior and tracking prevention.
121+type PrivacyConfig struct {
122+ // CookiePolicy controls cookie acceptance behavior.
123+ // Values: "always", "no_third_party", "never"
124+ CookiePolicy CookiePolicy `mapstructure:"cookie_policy" yaml:"cookie_policy" toml:"cookie_policy"`
125+ // ITPEnabled enables Intelligent Tracking Prevention.
126+ ITPEnabled bool `mapstructure:"itp_enabled" yaml:"itp_enabled" toml:"itp_enabled"`
127+}
128+
106 // HardwareDecodingMode controls video hardware acceleration.129 // HardwareDecodingMode controls video hardware acceleration.
107 type HardwareDecodingMode string130 type HardwareDecodingMode string
108 131
M internal/infrastructure/config/validation.go +13 -0
@@ -26,6 +26,7 @@ func validateConfig(config *Config) error {
26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)26 validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
27 validationErrors = append(validationErrors, validateOmnibox(config)...)27 validationErrors = append(validationErrors, validateOmnibox(config)...)
28 validationErrors = append(validationErrors, validateRendering(config)...)28 validationErrors = append(validationErrors, validateRendering(config)...)
29+ validationErrors = append(validationErrors, validatePrivacy(config)...)
29 validationErrors = append(validationErrors, validateColorScheme(config)...)30 validationErrors = append(validationErrors, validateColorScheme(config)...)
30 validationErrors = append(validationErrors, validateSession(config)...)31 validationErrors = append(validationErrors, validateSession(config)...)
31 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)32 validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
@@ -323,6 +324,18 @@ func validateRendering(config *Config) []string {
323 return validationErrors324 return validationErrors
324 }325 }
325 326
327+func validatePrivacy(config *Config) []string {
328+ switch config.Privacy.CookiePolicy {
329+ case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
330+ return nil
331+ default:
332+ return []string{fmt.Sprintf(
333+ "privacy.cookie_policy must be one of: always, no_third_party, never (got: %s)",
334+ config.Privacy.CookiePolicy,
335+ )}
336+ }
337+}
338+
326 func validateColorScheme(config *Config) []string {339 func validateColorScheme(config *Config) []string {
327 switch config.Appearance.ColorScheme {340 switch config.Appearance.ColorScheme {
328 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":341 case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
A internal/infrastructure/config/validation_test.go +36 -0
@@ -0,0 +1,36 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ cookiePolicy CookiePolicy
14+ wantErr bool
15+ }{
16+ {name: "always", cookiePolicy: CookiePolicyAlways, wantErr: false},
17+ {name: "no_third_party", cookiePolicy: CookiePolicyNoThirdParty, wantErr: false},
18+ {name: "never", cookiePolicy: CookiePolicyNever, wantErr: false},
19+ {name: "invalid", cookiePolicy: CookiePolicy("bad_policy"), wantErr: true},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ cfg := DefaultConfig()
25+ cfg.Privacy.CookiePolicy = tt.cookiePolicy
26+
27+ err := validateConfig(cfg)
28+ if tt.wantErr {
29+ require.Error(t, err)
30+ assert.Contains(t, err.Error(), "privacy.cookie_policy")
31+ return
32+ }
33+ require.NoError(t, err)
34+ })
35+ }
36+}
M internal/infrastructure/filtering/downloader.go +22 -9
@@ -183,33 +183,46 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
183 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)183 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)
184 }184 }
185 185
186- // Write to temp file first, then atomic rename186+ // Write to temp file in cacheDir, then atomic rename into destination.
187- tmpPath := localPath + ".tmp"187+ tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp")
188- file, err := os.Create(tmpPath)
189 if err != nil {188 if err != nil {
190 return "", 0, fmt.Errorf("failed to create temp file: %w", err)189 return "", 0, fmt.Errorf("failed to create temp file: %w", err)
191 }190 }
191+ tmpPath := tmpFile.Name()
192 192
193- bytesWritten, err := io.Copy(file, resp.Body)193+ bytesWritten, err := io.Copy(tmpFile, resp.Body)
194 if err != nil {194 if err != nil {
195- _ = file.Close()195+ _ = tmpFile.Close()
196 _ = os.Remove(tmpPath)196 _ = os.Remove(tmpPath)
197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
198 }198 }
199- if err := file.Close(); err != nil {199+ if err := tmpFile.Close(); err != nil {
200 _ = os.Remove(tmpPath)200 _ = os.Remove(tmpPath)
201 return "", 0, fmt.Errorf("failed to close temp file: %w", err)201 return "", 0, fmt.Errorf("failed to close temp file: %w", err)
202 }202 }
203 203
204- // Atomic rename204+ if err := d.renameTempFile(tmpPath, localPath); err != nil {
205- if err := os.Rename(tmpPath, localPath); err != nil {
206 _ = os.Remove(tmpPath)205 _ = os.Remove(tmpPath)
207- return "", 0, fmt.Errorf("failed to rename temp file: %w", err)206+ return "", 0, err
208 }207 }
209 208
210 return localPath, bytesWritten, nil209 return localPath, bytesWritten, nil
211 }210 }
212 211
212+func (*Downloader) renameTempFile(tmpPath, localPath string) error {
213+ parentDir := filepath.Dir(localPath)
214+
215+ if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil {
216+ return fmt.Errorf("failed to create target dir: %w", err)
217+ }
218+
219+ if err := os.Rename(tmpPath, localPath); err != nil {
220+ return fmt.Errorf("failed to rename temp file: %w", err)
221+ }
222+
223+ return nil
224+}
225+
213 // downloadAndCacheManifest downloads and caches the manifest.226 // downloadAndCacheManifest downloads and caches the manifest.
214 func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {227 func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
215 manifest, err := d.FetchManifest(ctx)228 manifest, err := d.FetchManifest(ctx)
A internal/infrastructure/filtering/downloader_test.go +84 -0
@@ -0,0 +1,84 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestDownloader_DownloadFile_RecoversWhenTargetDirDeletedConcurrently(t *testing.T) {
16+ t.Parallel()
17+
18+ cacheDir := filepath.Join(t.TempDir(), "cache")
19+ filename := filepath.Join("nested", "combined-part1.json")
20+ localPath := filepath.Join(cacheDir, filename)
21+ targetDir := filepath.Dir(localPath)
22+
23+ require.NoError(t, os.MkdirAll(targetDir, cacheDirPerm))
24+
25+ downloadStarted := make(chan struct{})
26+ allowResponse := make(chan struct{})
27+
28+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ if r.URL.Path == "/"+filename {
30+ close(downloadStarted)
31+ <-allowResponse
32+ _, _ = io.WriteString(w, `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`)
33+ return
34+ }
35+ http.NotFound(w, r)
36+ }))
37+ defer server.Close()
38+
39+ d := NewDownloader(cacheDir)
40+ d.baseURL = server.URL
41+
42+ type result struct {
43+ path string
44+ n int64
45+ err error
46+ }
47+ resultCh := make(chan result, 1)
48+ go func() {
49+ path, n, err := d.downloadFile(context.Background(), filename)
50+ resultCh <- result{path: path, n: n, err: err}
51+ }()
52+
53+ <-downloadStarted
54+ require.NoError(t, os.RemoveAll(targetDir))
55+ close(allowResponse)
56+
57+ res := <-resultCh
58+ require.NoError(t, res.err)
59+ require.Equal(t, localPath, res.path)
60+ require.Positive(t, res.n)
61+
62+ content, err := os.ReadFile(localPath)
63+ require.NoError(t, err)
64+ require.Contains(t, string(content), "url-filter")
65+}
66+
67+func TestDownloader_RenameTempFile_RequiresDirectoryParent(t *testing.T) {
68+ t.Parallel()
69+
70+ cacheDir := filepath.Join(t.TempDir(), "cache")
71+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
72+
73+ tmpFile := filepath.Join(cacheDir, "payload.tmp")
74+ require.NoError(t, os.WriteFile(tmpFile, []byte("payload"), manifestFilePerm))
75+
76+ parentAsFile := filepath.Join(cacheDir, "target")
77+ require.NoError(t, os.WriteFile(parentAsFile, []byte("not-a-directory"), manifestFilePerm))
78+
79+ d := NewDownloader(cacheDir)
80+
81+ err := d.renameTempFile(tmpFile, filepath.Join(parentAsFile, "filter.json"))
82+ require.Error(t, err)
83+ require.Contains(t, err.Error(), "not a directory")
84+}
M internal/infrastructure/filtering/manager.go +231 -71
@@ -3,9 +3,11 @@ package filtering
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "fmt"7 "fmt"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
10+ "strings"
9 "sync"11 "sync"
10 "sync/atomic"12 "sync/atomic"
11 "time"13 "time"
@@ -17,12 +19,17 @@ import (
17 const (19 const (
18 storeDirPerm = 0o75520 storeDirPerm = 0o755
19 jsonDirPerm = 0o75521 jsonDirPerm = 0o755
22+ quarantinePerm = 0o755
20 mergedFilePerm = 0o64423 mergedFilePerm = 0o644
21 24
22 // CacheMaxAge is the maximum age of the filter cache before it's considered stale.25 // CacheMaxAge is the maximum age of the filter cache before it's considered stale.
23 CacheMaxAge = 24 * time.Hour26 CacheMaxAge = 24 * time.Hour
24 )27 )
25 28
29+// ErrUpdateSkipped indicates a filter update was intentionally skipped after a recoverable
30+// update-path failure (e.g. invalid payload, merge failure, compile failure).
31+var ErrUpdateSkipped = errors.New("filter update skipped")
32+
26 // Manager orchestrates the content filter lifecycle.33 // Manager orchestrates the content filter lifecycle.
27 // It handles downloading, compiling, loading, and applying filters to WebViews.34 // It handles downloading, compiling, loading, and applying filters to WebViews.
28 type Manager struct {35 type Manager struct {
@@ -140,86 +147,125 @@ func (m *Manager) LoadAsync(ctx context.Context) {
140 return147 return
141 }148 }
142 149
143- go func() {150+ go m.loadAsyncWorker(ctx)
144- log := logging.FromContext(ctx).With().151+}
145- Str("component", "filter-manager").
146- Logger()
147-
148- // If already loaded, skip
149- m.filterMu.RLock()
150- if m.filter != nil {
151- m.filterMu.RUnlock()
152- log.Debug().Msg("filters already loaded, skipping async load")
153- return
154- }
155- m.filterMu.RUnlock()
156-
157- // Try to load from cache first (fast path)
158- m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
159- if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
160- log.Debug().Msg("found compiled filter, loading from cache")
161- filter, err := m.store.Load(ctx, FilterIdentifier)
162- if err == nil && filter != nil {
163- m.filterMu.Lock()
164- m.filter = filter
165- m.filterMu.Unlock()
166-
167- version := m.getCachedVersion()
168- m.setStatus(FilterStatus{
169- State: StateActive,
170- Message: "Filters active",
171- Version: version,
172- })
173- log.Info().Str("version", version).Msg("filters loaded from cache")
174-
175- m.checkStaleCacheAndUpdate(ctx)
176- return
177- }
178- log.Warn().Err(err).Msg("failed to load cached filter, will download")
179- }
180 152
181- // Download filters153+func (m *Manager) loadAsyncWorker(ctx context.Context) {
182- m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})154+ log := logging.FromContext(ctx).With().
183- paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {155+ Str("component", "filter-manager").
184- msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)156+ Logger()
185- m.setStatus(FilterStatus{State: StateLoading, Message: msg})
186- })
187- if err != nil {
188- log.Error().Err(err).Msg("failed to download filters")
189- m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
190- return
191- }
192 157
193- m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})158+ if m.hasActiveFilter() {
159+ log.Debug().Msg("filters already loaded, skipping async load")
160+ return
161+ }
194 162
195- // Merge JSON files into one for compilation163+ if m.loadFromCache(ctx) {
196- mergedPath, err := m.mergeJSONFiles(ctx, paths)164+ m.checkStaleCacheAndUpdate(ctx)
197- if err != nil {165+ return
198- log.Error().Err(err).Msg("failed to merge filter files")166+ }
199- m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
200- return
201- }
202 167
203- // Compile the merged filter168+ m.downloadCompileAndActivate(ctx)
204- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)169+}
205- if err != nil {
206- log.Error().Err(err).Msg("failed to compile filters")
207- m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
208- return
209- }
210 170
211- m.filterMu.Lock()171+func (m *Manager) loadFromCache(ctx context.Context) bool {
212- m.filter = filter172+ log := logging.FromContext(ctx).With().
213- m.filterMu.Unlock()173+ Str("component", "filter-manager").
174+ Logger()
214 175
176+ m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
177+ if !m.store.HasCompiledFilter(ctx, FilterIdentifier) {
178+ return false
179+ }
180+
181+ log.Debug().Msg("found compiled filter, loading from cache")
182+ filter, err := m.store.Load(ctx, FilterIdentifier)
183+ if err != nil || filter == nil {
184+ log.Warn().Err(err).Msg("failed to load cached filter, will download")
185+ return false
186+ }
187+
188+ version := m.setActiveFilter(filter, "Filters active")
189+ log.Info().Str("version", version).Msg("filters loaded from cache")
190+ return true
191+}
192+
193+func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
194+ log := logging.FromContext(ctx).With().
195+ Str("component", "filter-manager").
196+ Logger()
197+
198+ paths, err := m.downloadFiltersWithProgress(ctx)
199+ if err != nil {
200+ log.Error().Err(err).Msg("failed to download filters")
201+ m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
202+ return
203+ }
204+
205+ validateErr := m.validateDownloadedFiles(ctx, paths)
206+ if validateErr != nil {
207+ log.Error().Err(validateErr).Msg("downloaded filter validation failed")
208+ m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
209+ return
210+ }
211+
212+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
213+ mergedPath, err := m.mergeJSONFiles(ctx, paths)
214+ if err != nil {
215+ log.Error().Err(err).Msg("failed to merge filter files")
216+ m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
217+ return
218+ }
219+
220+ filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
221+ if err != nil {
222+ log.Error().Err(err).Msg("failed to compile filters")
223+ m.handleCompilationFailure()
224+ return
225+ }
226+ if filter == nil {
227+ log.Error().Msg("filter compilation returned nil filter")
228+ m.handleCompilationFailure()
229+ return
230+ }
231+
232+ version := m.setActiveFilter(filter, "Filters active")
233+ log.Info().Str("version", version).Msg("filters compiled and active")
234+}
235+
236+func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
237+ m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
238+ return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
239+ msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
240+ m.setStatus(FilterStatus{State: StateLoading, Message: msg})
241+ })
242+}
243+
244+func (m *Manager) handleCompilationFailure() {
245+ if m.hasActiveFilter() {
215 version := m.getCachedVersion()246 version := m.getCachedVersion()
216 m.setStatus(FilterStatus{247 m.setStatus(FilterStatus{
217 State: StateActive,248 State: StateActive,
218- Message: "Filters active",249+ Message: "Filters active (update skipped)",
219 Version: version,250 Version: version,
220 })251 })
221- log.Info().Str("version", version).Msg("filters compiled and active")252+ return
222- }()253+ }
254+ m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
255+}
256+
257+func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message string) string {
258+ m.filterMu.Lock()
259+ m.filter = filter
260+ m.filterMu.Unlock()
261+
262+ version := m.getCachedVersion()
263+ m.setStatus(FilterStatus{
264+ State: StateActive,
265+ Message: message,
266+ Version: version,
267+ })
268+ return version
223 }269 }
224 270
225 // mergeJSONFiles combines multiple JSON rule files into one.271 // mergeJSONFiles combines multiple JSON rule files into one.
@@ -345,16 +391,27 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
345 if err != nil {391 if err != nil {
346 return fmt.Errorf("failed to download filter update: %w", err)392 return fmt.Errorf("failed to download filter update: %w", err)
347 }393 }
394+ validateErr := m.validateDownloadedFiles(ctx, paths)
395+ if validateErr != nil {
396+ log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
397+ return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
398+ }
348 399
349 // Merge and compile400 // Merge and compile
350 mergedPath, err := m.mergeJSONFiles(ctx, paths)401 mergedPath, err := m.mergeJSONFiles(ctx, paths)
351 if err != nil {402 if err != nil {
352- return fmt.Errorf("failed to merge updated filters: %w", err)403+ log.Warn().Err(err).Msg("failed to merge updated filters, keeping existing active filter")
404+ return fmt.Errorf("%w: merge failed: %w", ErrUpdateSkipped, err)
353 }405 }
354 406
355 filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)407 filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
356 if err != nil {408 if err != nil {
357- return fmt.Errorf("failed to compile updated filters: %w", err)409+ log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")
410+ return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
411+ }
412+ if filter == nil {
413+ log.Warn().Msg("updated filter compilation returned nil filter, keeping existing active filter")
414+ return fmt.Errorf("%w: compile returned nil filter", ErrUpdateSkipped)
358 }415 }
359 416
360 m.filterMu.Lock()417 m.filterMu.Lock()
@@ -372,6 +429,109 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
372 return nil429 return nil
373 }430 }
374 431
432+func (m *Manager) hasActiveFilter() bool {
433+ m.filterMu.RLock()
434+ defer m.filterMu.RUnlock()
435+ return m.filter != nil
436+}
437+
438+func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
439+ var validationErrs []error
440+ var quarantineErrs []error
441+
442+ for _, path := range paths {
443+ if err := validateRuleFile(path); err != nil {
444+ if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
445+ quarantineErrs = append(quarantineErrs, fmt.Errorf("file=%s: %w", path, quarantineErr))
446+ }
447+ validationErrs = append(validationErrs, fmt.Errorf("file=%s: %w", path, err))
448+ }
449+ }
450+
451+ if len(validationErrs) == 0 && len(quarantineErrs) == 0 {
452+ return nil
453+ }
454+
455+ var parts []string
456+ if len(validationErrs) > 0 {
457+ parts = append(parts, fmt.Sprintf("%d invalid file(s)", len(validationErrs)))
458+ }
459+ if len(quarantineErrs) > 0 {
460+ parts = append(parts, fmt.Sprintf("%d quarantine failure(s)", len(quarantineErrs)))
461+ }
462+
463+ return fmt.Errorf(
464+ "downloaded filter validation failed (%s): %w",
465+ strings.Join(parts, ", "),
466+ errors.Join(append(validationErrs, quarantineErrs...)...),
467+ )
468+}
469+
470+func validateRuleFile(path string) error {
471+ data, err := os.ReadFile(path)
472+ if err != nil {
473+ return fmt.Errorf("read failed: %w", err)
474+ }
475+
476+ var rules []map[string]json.RawMessage
477+ if err := json.Unmarshal(data, &rules); err != nil {
478+ return fmt.Errorf("json parse failed: %w", err)
479+ }
480+ if len(rules) == 0 {
481+ return fmt.Errorf("empty rule set")
482+ }
483+
484+ for i, rule := range rules {
485+ trigger, ok := rule["trigger"]
486+ if !ok || len(trigger) == 0 || string(trigger) == "null" {
487+ return fmt.Errorf("rule[%d] missing trigger", i)
488+ }
489+ action, ok := rule["action"]
490+ if !ok || len(action) == 0 || string(action) == "null" {
491+ return fmt.Errorf("rule[%d] missing action", i)
492+ }
493+ var actionObj struct {
494+ Type string `json:"type"`
495+ }
496+ if err := json.Unmarshal(action, &actionObj); err != nil {
497+ return fmt.Errorf("rule[%d] action parse failed: %w", i, err)
498+ }
499+ if actionObj.Type == "" {
500+ return fmt.Errorf("rule[%d] action.type missing", i)
501+ }
502+ }
503+
504+ return nil
505+}
506+
507+func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
508+ log := logging.FromContext(ctx).With().
509+ Str("component", "filter-manager").
510+ Logger()
511+
512+ quarantineDir := filepath.Join(m.jsonDir, "quarantine")
513+ if err := os.MkdirAll(quarantineDir, quarantinePerm); err != nil {
514+ return fmt.Errorf("create quarantine dir failed: %w", err)
515+ }
516+
517+ baseName := filepath.Base(srcPath)
518+ quarantinePath := filepath.Join(
519+ quarantineDir,
520+ fmt.Sprintf("%d-%s", time.Now().UnixNano(), baseName),
521+ )
522+ if err := os.Rename(srcPath, quarantinePath); err != nil {
523+ return fmt.Errorf("move to quarantine failed: %w", err)
524+ }
525+
526+ log.Error().
527+ Str("path", srcPath).
528+ Str("quarantine_path", quarantinePath).
529+ Str("cause", cause).
530+ Msg("quarantined invalid filter file")
531+
532+ return nil
533+}
534+
375 // Enable enables content filtering.535 // Enable enables content filtering.
376 func (m *Manager) Enable(ctx context.Context) {536 func (m *Manager) Enable(ctx context.Context) {
377 log := logging.FromContext(ctx).With().537 log := logging.FromContext(ctx).With().
M internal/infrastructure/filtering/manager_test.go +117 -4
@@ -2,6 +2,7 @@ package filtering_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "sync"8 "sync"
@@ -11,6 +12,7 @@ import (
11 "github.com/bnema/dumber/internal/infrastructure/filtering"12 "github.com/bnema/dumber/internal/infrastructure/filtering"
12 "github.com/bnema/dumber/internal/infrastructure/filtering/mocks"13 "github.com/bnema/dumber/internal/infrastructure/filtering/mocks"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15+ "github.com/bnema/puregotk-webkit/webkit"
14 "github.com/stretchr/testify/assert"16 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"17 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
@@ -54,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
54 56
55 mockStore.EXPECT().57 mockStore.EXPECT().
56 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).58 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
57- Return(nil, nil)59+ Return(&webkit.UserContentFilter{}, nil)
58 60
59 mockDownloader.EXPECT().61 mockDownloader.EXPECT().
60 GetCachedManifest().62 GetCachedManifest().
@@ -144,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
144 // Compile should be called146 // Compile should be called
145 mockStore.EXPECT().147 mockStore.EXPECT().
146 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).148 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
147- Return(nil, nil)149+ Return(&webkit.UserContentFilter{}, nil)
148 150
149 // GetCachedManifest for version151 // GetCachedManifest for version
150 mockDownloader.EXPECT().152 mockDownloader.EXPECT().
@@ -241,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
241 // Compile called243 // Compile called
242 mockStore.EXPECT().244 mockStore.EXPECT().
243 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).245 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
244- Return(nil, nil)246+ Return(&webkit.UserContentFilter{}, nil)
245 247
246 // GetCachedManifest for version248 // GetCachedManifest for version
247 mockDownloader.EXPECT().249 mockDownloader.EXPECT().
@@ -451,7 +453,7 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
451 Return([]string{jsonFile}, nil)453 Return([]string{jsonFile}, nil)
452 mockStore.EXPECT().454 mockStore.EXPECT().
453 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).455 Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
454- Return(nil, nil)456+ Return(&webkit.UserContentFilter{}, nil)
455 mockDownloader.EXPECT().457 mockDownloader.EXPECT().
456 GetCachedManifest().458 GetCachedManifest().
457 Return(&filtering.Manifest{Version: "test"}, nil)459 Return(&filtering.Manifest{Version: "test"}, nil)
@@ -494,3 +496,114 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
494 defer mu.Unlock()496 defer mu.Unlock()
495 assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice")497 assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice")
496 }498 }
499+
500+func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
501+ tmpDir := t.TempDir()
502+ storeDir := filepath.Join(tmpDir, "store")
503+ jsonDir := filepath.Join(tmpDir, "json")
504+
505+ invalidJSONFile := filepath.Join(jsonDir, "combined-part1.json")
506+ require.NoError(t, os.MkdirAll(jsonDir, 0o755))
507+ require.NoError(t, os.WriteFile(invalidJSONFile, []byte(`{"invalid":`), 0o644))
508+
509+ mockStore := mocks.NewMockFilterStore(t)
510+ mockDownloader := mocks.NewMockFilterDownloader(t)
511+
512+ mockStore.EXPECT().
513+ HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
514+ Return(false)
515+
516+ mockDownloader.EXPECT().
517+ DownloadFilters(mock.Anything, mock.Anything).
518+ Return([]string{invalidJSONFile}, nil)
519+
520+ mgr, err := filtering.NewManager(filtering.ManagerConfig{
521+ StoreDir: storeDir,
522+ JSONDir: jsonDir,
523+ Enabled: true,
524+ AutoUpdate: false,
525+ Store: mockStore,
526+ Downloader: mockDownloader,
527+ })
528+ require.NoError(t, err)
529+
530+ ctx := testContext()
531+ require.NoError(t, mgr.Initialize(ctx))
532+
533+ mgr.LoadAsync(ctx)
534+
535+ require.Eventually(t, func() bool {
536+ return mgr.Status().State == filtering.StateError
537+ }, 3*time.Second, 50*time.Millisecond)
538+ assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)
539+
540+ _, statErr := os.Stat(invalidJSONFile)
541+ require.ErrorIs(t, statErr, os.ErrNotExist)
542+
543+ quarantineDir := filepath.Join(jsonDir, "quarantine")
544+ entries, err := os.ReadDir(quarantineDir)
545+ require.NoError(t, err)
546+ require.Len(t, entries, 1)
547+}
548+
549+func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
550+ tmpDir := t.TempDir()
551+ storeDir := filepath.Join(tmpDir, "store")
552+ jsonDir := filepath.Join(tmpDir, "json")
553+
554+ validJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
555+ jsonFile := filepath.Join(jsonDir, "combined-part1.json")
556+ require.NoError(t, os.MkdirAll(jsonDir, 0o755))
557+ require.NoError(t, os.WriteFile(jsonFile, []byte(validJSON), 0o644))
558+
559+ mockStore := mocks.NewMockFilterStore(t)
560+ mockDownloader := mocks.NewMockFilterDownloader(t)
561+
562+ activeFilter := &webkit.UserContentFilter{}
563+
564+ mockStore.EXPECT().
565+ HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
566+ Return(true).Once()
567+ mockStore.EXPECT().
568+ Load(mock.Anything, filtering.FilterIdentifier).
569+ Return(activeFilter, nil).Once()
570+ mockDownloader.EXPECT().
571+ GetCachedManifest().
572+ Return(&filtering.Manifest{Version: "2025.12.19"}, nil).Once()
573+ mockDownloader.EXPECT().
574+ IsCacheStale(filtering.CacheMaxAge).
575+ Return(false).Once()
576+
577+ mockDownloader.EXPECT().
578+ NeedsUpdate(mock.Anything).
579+ Return(true, nil).Once()
580+ mockDownloader.EXPECT().
581+ DownloadFilters(mock.Anything, mock.Anything).
582+ Return([]string{jsonFile}, nil).Once()
583+ mockStore.EXPECT().
584+ Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
585+ Return(nil, errors.New("compile failed")).Once()
586+
587+ mgr, err := filtering.NewManager(filtering.ManagerConfig{
588+ StoreDir: storeDir,
589+ JSONDir: jsonDir,
590+ Enabled: true,
591+ AutoUpdate: true,
592+ Store: mockStore,
593+ Downloader: mockDownloader,
594+ })
595+ require.NoError(t, err)
596+
597+ ctx := testContext()
598+ require.NoError(t, mgr.Initialize(ctx))
599+ mgr.LoadAsync(ctx)
600+
601+ require.Eventually(t, func() bool {
602+ return mgr.GetFilter() != nil && mgr.Status().State == filtering.StateActive
603+ }, 3*time.Second, 50*time.Millisecond)
604+
605+ err = mgr.CheckForUpdates(ctx)
606+ require.ErrorIs(t, err, filtering.ErrUpdateSkipped)
607+ assert.Same(t, activeFilter, mgr.GetFilter())
608+ assert.Equal(t, filtering.StateActive, mgr.Status().State)
609+}
M internal/infrastructure/persistence/sqlite/history_repo.go +85 -26
@@ -6,7 +6,9 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "strings"8 "strings"
9+ "sync/atomic"
9 "time"10 "time"
11+ "unicode"
10 12
11 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/repository"14 "github.com/bnema/dumber/internal/domain/repository"
@@ -16,6 +18,8 @@ import (
16 18
17 const logURLMaxLen = 6019 const logURLMaxLen = 60
18 20
21+var expectedFTSFailureCount atomic.Uint64
22+
19 type historyRepo struct {23 type historyRepo struct {
20 queries *sqlc.Queries24 queries *sqlc.Queries
21 }25 }
@@ -68,28 +72,15 @@ func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.Histor
68 }72 }
69 73
70 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {74 func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
71- log := logging.FromContext(ctx)75+ words := sanitizeFTS5QueryTokens(query)
72-
73- // Replace separators with spaces before splitting - FTS5 tokenizer treats these as separators
74- // so "gordon.bne" or "github.com/bnema" are indexed as separate tokens
75- // This allows searching "gordon.bne" or "github.com/bnema" to match URLs containing both terms
76- normalizedQuery := strings.NewReplacer(
77- ".", " ",
78- "/", " ",
79- ).Replace(query)
80-
81- // Split query into words and add prefix matching to each
82- // This enables multi-word searches like "github issues" -> "github* issues*"
83- words := strings.Fields(normalizedQuery)
84 if len(words) == 0 {76 if len(words) == 0 {
85 return []entity.HistoryMatch{}, nil77 return []entity.HistoryMatch{}, nil
86 }78 }
87 79
88 // Build FTS5 query: "word1* word2*" (implicit AND between terms)80 // Build FTS5 query: "word1* word2*" (implicit AND between terms)
89- // Sanitize each word to remove FTS5 special characters
90 parts := make([]string, len(words))81 parts := make([]string, len(words))
91 for i, word := range words {82 for i, word := range words {
92- parts[i] = sanitizeFTS5Word(word) + "*"83+ parts[i] = word + "*"
93 }84 }
94 ftsQuery := strings.Join(parts, " ")85 ftsQuery := strings.Join(parts, " ")
95 86
@@ -97,12 +88,12 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
97 // Use domain-boosted query for URL search to prioritize domain matches88 // Use domain-boosted query for URL search to prioritize domain matches
98 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)89 // Use sanitized first word for domain boost (intentional: domain boost targets first search term)
99 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{90 urlRows, urlErr := r.queries.SearchHistoryFTSUrlWithDomainBoost(ctx, sqlc.SearchHistoryFTSUrlWithDomainBoostParams{
100- Term: sql.NullString{String: sanitizeFTS5Word(words[0]), Valid: true},91+ Term: sql.NullString{String: words[0], Valid: true},
101 Query: ftsQuery,92 Query: ftsQuery,
102 Limit: int64(limit),93 Limit: int64(limit),
103 })94 })
104 if urlErr != nil {95 if urlErr != nil {
105- log.Debug().Err(urlErr).Str("query", query).Msg("FTS URL search failed")96+ logFTSSearchError(ctx, "url", query, urlErr)
106 urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}97 urlRows = []sqlc.SearchHistoryFTSUrlWithDomainBoostRow{}
107 }98 }
108 99
@@ -111,7 +102,7 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
111 Limit: int64(limit),102 Limit: int64(limit),
112 })103 })
113 if titleErr != nil {104 if titleErr != nil {
114- log.Debug().Err(titleErr).Str("query", query).Msg("FTS title search failed")105+ logFTSSearchError(ctx, "title", query, titleErr)
115 titleRows = []sqlc.History{}106 titleRows = []sqlc.History{}
116 }107 }
117 108
@@ -163,23 +154,77 @@ func historyFromDomainBoostRow(row sqlc.SearchHistoryFTSUrlWithDomainBoostRow) *
163 }154 }
164 }155 }
165 156
166-// sanitizeFTS5Word removes FTS5 special characters from a search word.157+// sanitizeFTS5QueryTokens normalizes separators and returns only valid FTS tokens.
167-// FTS5 special chars: " * ( ) : ^ - AND OR NOT NEAR158+func sanitizeFTS5QueryTokens(query string) []string {
168-// Note: Periods are handled earlier by replacing with spaces (tokenizer separator)159+ normalized := normalizeFTSSeparators(query)
160+ rawTokens := strings.Fields(normalized)
161+ if len(rawTokens) == 0 {
162+ return nil
163+ }
164+
165+ tokens := make([]string, 0, len(rawTokens))
166+ for _, token := range rawTokens {
167+ sanitized := sanitizeFTS5Word(token)
168+ if sanitized == "" {
169+ continue
170+ }
171+ upper := strings.ToUpper(sanitized)
172+ if upper == "AND" || upper == "OR" || upper == "NOT" || upper == "NEAR" {
173+ continue
174+ }
175+ tokens = append(tokens, sanitized)
176+ }
177+ return tokens
178+}
179+
180+// normalizeFTSSeparators replaces non-alphanumeric separators with spaces.
181+func normalizeFTSSeparators(s string) string {
182+ var b strings.Builder
183+ b.Grow(len(s))
184+ for _, r := range s {
185+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
186+ b.WriteRune(r)
187+ } else {
188+ b.WriteByte(' ')
189+ }
190+ }
191+ return b.String()
192+}
193+
194+// sanitizeFTS5Word keeps only alphanumeric runes in a single token.
169 func sanitizeFTS5Word(word string) string {195 func sanitizeFTS5Word(word string) string {
170- // Remove characters that have special meaning in FTS5
171 var result strings.Builder196 var result strings.Builder
197+ result.Grow(len(word))
172 for _, r := range word {198 for _, r := range word {
173- switch r {199+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
174- case '"', '*', '(', ')', ':', '^', '-', '/':
175- // Skip FTS5 special characters
176- default:
177 result.WriteRune(r)200 result.WriteRune(r)
178 }201 }
179 }202 }
180 return result.String()203 return result.String()
181 }204 }
182 205
206+func logFTSSearchError(ctx context.Context, part, query string, err error) {
207+ log := logging.FromContext(ctx)
208+ if isExpectedFTSError(err) {
209+ count := expectedFTSFailureCount.Add(1)
210+ if count == 1 || count%100 == 0 {
211+ log.Debug().
212+ Uint64("expected_error_count", count).
213+ Str("part", part).
214+ Msg("expected FTS errors observed")
215+ }
216+ return
217+ }
218+ log.Debug().Err(err).Str("part", part).Str("query", query).Msg("FTS search failed")
219+}
220+
221+func isExpectedFTSError(err error) bool {
222+ msg := strings.ToLower(err.Error())
223+ return strings.Contains(msg, "malformed match") ||
224+ strings.Contains(msg, "fts5: syntax error") ||
225+ strings.Contains(msg, "unterminated string")
226+}
227+
183 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {228 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
184 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{229 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
185 Limit: int64(limit),230 Limit: int64(limit),
@@ -266,6 +311,20 @@ func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error
266 return r.queries.IncrementVisitCount(ctx, url)311 return r.queries.IncrementVisitCount(ctx, url)
267 }312 }
268 313
314+func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {
315+ // Skip incrementing about:blank - it should always have visit_count = 1
316+ if url == aboutBlankURL {
317+ return nil
318+ }
319+ if delta <= 0 {
320+ return fmt.Errorf("visit delta must be > 0, got %d", delta)
321+ }
322+ return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323+ VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
324+ Url: url,
325+ })
326+}
327+
269 func (r *historyRepo) Delete(ctx context.Context, id int64) error {328 func (r *historyRepo) Delete(ctx context.Context, id int64) error {
270 return r.queries.DeleteHistoryByID(ctx, id)329 return r.queries.DeleteHistoryByID(ctx, id)
271 }330 }
M internal/infrastructure/persistence/sqlite/history_repo_test.go +50 -0
@@ -429,6 +429,35 @@ func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
429 assert.Empty(t, results)429 assert.Empty(t, results)
430 }430 }
431 431
432+func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
433+ ctx := historyTestCtx()
434+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
435+
436+ db, err := sqlite.NewConnection(ctx, dbPath)
437+ require.NoError(t, err)
438+ t.Cleanup(func() { _ = db.Close() })
439+
440+ repo := sqlite.NewHistoryRepository(db)
441+
442+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
443+ URL: "https://example.com",
444+ Title: "Example",
445+ }))
446+
447+ invalidQueries := []string{
448+ "!!!",
449+ "___",
450+ "AND OR NOT NEAR",
451+ `"***"`,
452+ }
453+
454+ for _, q := range invalidQueries {
455+ results, err := repo.Search(ctx, q, 10)
456+ require.NoError(t, err, "query %q should return early without SQL errors", q)
457+ assert.Empty(t, results, "query %q should have no valid tokens", q)
458+ }
459+}
460+
432 func TestHistoryRepository_Search_NoResults(t *testing.T) {461 func TestHistoryRepository_Search_NoResults(t *testing.T) {
433 ctx := historyTestCtx()462 ctx := historyTestCtx()
434 dbPath := filepath.Join(t.TempDir(), "dumber.db")463 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -557,6 +586,27 @@ func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
557 }586 }
558 }587 }
559 588
589+func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
590+ ctx := historyTestCtx()
591+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
592+
593+ db, err := sqlite.NewConnection(ctx, dbPath)
594+ require.NoError(t, err)
595+ t.Cleanup(func() { _ = db.Close() })
596+
597+ repo := sqlite.NewHistoryRepository(db)
598+
599+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
600+ URL: "https://github.com/bnema/dumber",
601+ Title: "Dumber",
602+ }))
603+
604+ results, err := repo.Search(ctx, "AND github OR", 10)
605+ require.NoError(t, err)
606+ require.Len(t, results, 1)
607+ assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
608+}
609+
560 func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {610 func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561 ctx := historyTestCtx()611 ctx := historyTestCtx()
562 dbPath := filepath.Join(t.TempDir(), "dumber.db")612 dbPath := filepath.Join(t.TempDir(), "dumber.db")
M internal/infrastructure/persistence/sqlite/lazy_repos.go +26 -0
@@ -18,6 +18,7 @@ package sqlite
18 import (18 import (
19 "context"19 "context"
20 "database/sql"20 "database/sql"
21+ "fmt"
21 "sync"22 "sync"
22 "time"23 "time"
23 24
@@ -114,6 +115,31 @@ func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url str
114 return r.repo.IncrementVisitCount(ctx, url)115 return r.repo.IncrementVisitCount(ctx, url)
115 }116 }
116 117
118+func (r *LazyHistoryRepository) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {
119+ if delta <= 0 {
120+ return fmt.Errorf("visit delta must be > 0, got %d", delta)
121+ }
122+ if err := r.init(ctx); err != nil {
123+ return err
124+ }
125+ if deltaRepo, ok := r.repo.(interface {
126+ IncrementVisitCountBy(context.Context, string, int) error
127+ }); ok {
128+ return deltaRepo.IncrementVisitCountBy(ctx, url, delta)
129+ }
130+ // Fallback: single-increment loop for repos that don't support delta increment.
131+ const fallbackCap = 256
132+ if delta > fallbackCap {
133+ return fmt.Errorf("visit delta %d exceeds fallback cap %d and repo does not support IncrementVisitCountBy", delta, fallbackCap)
134+ }
135+ for i := 0; i < delta; i++ {
136+ if err := r.repo.IncrementVisitCount(ctx, url); err != nil {
137+ return err
138+ }
139+ }
140+ return nil
141+}
142+
117 func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {143 func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
118 if err := r.init(ctx); err != nil {144 if err := r.init(ctx); err != nil {
119 return err145 return err
M internal/infrastructure/persistence/sqlite/queries/history.sql +5 -0
@@ -19,6 +19,11 @@ UPDATE history
19 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP19 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
20 WHERE url = ?;20 WHERE url = ?;
21 21
22+-- name: IncrementVisitCountByDelta :exec
23+UPDATE history
24+SET visit_count = visit_count + ?, last_visited = CURRENT_TIMESTAMP
25+WHERE url = ?;
26+
22 -- name: SearchHistory :many27 -- name: SearchHistory :many
23 SELECT * FROM history28 SELECT * FROM history
24 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'29 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
M internal/infrastructure/persistence/sqlite/session_state_repo.go +27 -3
@@ -5,6 +5,7 @@ import (
5 "database/sql"5 "database/sql"
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8+ "fmt"
8 9
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/domain/repository"11 "github.com/bnema/dumber/internal/domain/repository"
@@ -13,12 +14,16 @@ import (
13 )14 )
14 15
15 type sessionStateRepo struct {16 type sessionStateRepo struct {
17+ db *sql.DB
16 queries *sqlc.Queries18 queries *sqlc.Queries
17 }19 }
18 20
19 // NewSessionStateRepository creates a new session state repository.21 // NewSessionStateRepository creates a new session state repository.
20 func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {22 func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
21- return &sessionStateRepo{queries: sqlc.New(db)}23+ return &sessionStateRepo{
24+ db: db,
25+ queries: sqlc.New(db),
26+ }
22 }27 }
23 28
24 // SaveSnapshot saves or updates a session state snapshot.29 // SaveSnapshot saves or updates a session state snapshot.
@@ -40,14 +45,33 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi
40 Int("pane_count", state.CountPanes()).45 Int("pane_count", state.CountPanes()).
41 Msg("saving session state snapshot")46 Msg("saving session state snapshot")
42 47
43- return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{48+ tx, err := r.db.BeginTx(ctx, nil)
49+ if err != nil {
50+ return fmt.Errorf("begin snapshot transaction: %w", err)
51+ }
52+ defer func() {
53+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
54+ log.Debug().Err(rollbackErr).Msg("snapshot rollback reported non-terminal error")
55+ }
56+ }()
57+
58+ txQueries := r.queries.WithTx(tx)
59+ if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
44 SessionID: string(state.SessionID),60 SessionID: string(state.SessionID),
45 StateJson: string(stateJSON),61 StateJson: string(stateJSON),
46 Version: int64(state.Version),62 Version: int64(state.Version),
47 TabCount: int64(len(state.Tabs)),63 TabCount: int64(len(state.Tabs)),
48 PaneCount: int64(state.CountPanes()),64 PaneCount: int64(state.CountPanes()),
49 UpdatedAt: state.SavedAt,65 UpdatedAt: state.SavedAt,
50- })66+ }); err != nil {
67+ return err
68+ }
69+
70+ if err := tx.Commit(); err != nil {
71+ return fmt.Errorf("commit snapshot transaction: %w", err)
72+ }
73+
74+ return nil
51 }75 }
52 76
53 // GetSnapshot returns the latest snapshot for a session.77 // GetSnapshot returns the latest snapshot for a session.
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +16 -0
@@ -410,6 +410,22 @@ func (q *Queries) IncrementVisitCount(ctx context.Context, url string) error {
410 return err410 return err
411 }411 }
412 412
413+const IncrementVisitCountByDelta = `-- name: IncrementVisitCountByDelta :exec
414+UPDATE history
415+SET visit_count = visit_count + ?, last_visited = CURRENT_TIMESTAMP
416+WHERE url = ?
417+`
418+
419+type IncrementVisitCountByDeltaParams struct {
420+ VisitCount sql.NullInt64 `json:"visit_count"`
421+ Url string `json:"url"`
422+}
423+
424+func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error {
425+ _, err := q.db.ExecContext(ctx, IncrementVisitCountByDelta, arg.VisitCount, arg.Url)
426+ return err
427+}
428+
413 const SearchHistory = `-- name: SearchHistory :many429 const SearchHistory = `-- name: SearchHistory :many
414 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history430 SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
415 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'431 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -0
@@ -64,6 +64,7 @@ type Querier interface {
64 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)64 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
65 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)65 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
66 IncrementVisitCount(ctx context.Context, url string) error66 IncrementVisitCount(ctx context.Context, url string) error
67+ IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
67 InsertSession(ctx context.Context, arg InsertSessionParams) error68 InsertSession(ctx context.Context, arg InsertSessionParams) error
68 IsWhitelisted(ctx context.Context, domain string) (int64, error)69 IsWhitelisted(ctx context.Context, domain string) (int64, error)
69 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)70 ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
M internal/infrastructure/snapshot/service.go +113 -4
@@ -2,6 +2,9 @@ package snapshot
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "fmt"
7+ "strings"
5 "sync"8 "sync"
6 "time"9 "time"
7 10
@@ -10,11 +13,19 @@ import (
10 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
11 )14 )
12 15
16+const (
17+ defaultSnapshotIntervalMs = 5000
18+ maxFKRetries = 3
19+ fkRetryDelay = 100 * time.Millisecond
20+)
21+
13 // Service handles debounced session state snapshots.22 // Service handles debounced session state snapshots.
14 type Service struct {23 type Service struct {
15 snapshotUC *usecase.SnapshotSessionUseCase24 snapshotUC *usecase.SnapshotSessionUseCase
16 provider port.TabListProvider25 provider port.TabListProvider
17 interval time.Duration26 interval time.Duration
27+ retries int
28+ retryDelay time.Duration
18 29
19 mu sync.Mutex30 mu sync.Mutex
20 timer *time.Timer31 timer *time.Timer
@@ -31,12 +42,14 @@ func NewService(
31 intervalMs int,42 intervalMs int,
32 ) *Service {43 ) *Service {
33 if intervalMs <= 0 {44 if intervalMs <= 0 {
34- intervalMs = 5000 // Default 5 seconds45+ intervalMs = defaultSnapshotIntervalMs
35 }46 }
36 return &Service{47 return &Service{
37 snapshotUC: snapshotUC,48 snapshotUC: snapshotUC,
38 provider: provider,49 provider: provider,
39 interval: time.Duration(intervalMs) * time.Millisecond,50 interval: time.Duration(intervalMs) * time.Millisecond,
51+ retries: maxFKRetries,
52+ retryDelay: fkRetryDelay,
40 }53 }
41 }54 }
42 55
@@ -54,8 +67,24 @@ func (s *Service) Start(ctx context.Context) {
54 // to avoid FK constraint violations.67 // to avoid FK constraint violations.
55 func (s *Service) SetReady() {68 func (s *Service) SetReady() {
56 s.mu.Lock()69 s.mu.Lock()
57- defer s.mu.Unlock()70+ if s.timer != nil {
71+ s.timer.Stop()
72+ s.timer = nil
73+ }
58 s.ready = true74 s.ready = true
75+ dirty := s.dirty
76+ ctx := s.ctx
77+ s.mu.Unlock()
78+
79+ if !dirty || ctx == nil {
80+ return
81+ }
82+
83+ go func() {
84+ if err := s.saveSnapshot(ctx); err != nil {
85+ logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready")
86+ }
87+ }()
59 }88 }
60 89
61 // Stop stops the service and saves final state.90 // Stop stops the service and saves final state.
@@ -135,11 +164,91 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
135 sessionID := s.provider.GetSessionID()164 sessionID := s.provider.GetSessionID()
136 165
137 if sessionID == "" {166 if sessionID == "" {
167+ s.markDirty()
138 return nil168 return nil
139 }169 }
140 170
141- return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{171+ if err := s.executeWithRetry(ctx, usecase.SnapshotInput{
142 SessionID: sessionID,172 SessionID: sessionID,
143 TabList: tabList,173 TabList: tabList,
144- })174+ }); err != nil {
175+ s.markDirty()
176+ return err
177+ }
178+
179+ return nil
180+}
181+
182+func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
183+ log := logging.FromContext(ctx)
184+ maxAttempts := s.retries + 1
185+
186+ var lastErr error
187+ for attempt := 1; attempt <= maxAttempts; attempt++ {
188+ if err := s.snapshotUC.Execute(ctx, input); err != nil {
189+ lastErr = err
190+ if !isTransientFKError(err) || attempt == maxAttempts {
191+ if attempt == maxAttempts && isTransientFKError(err) {
192+ log.Error().
193+ Err(err).
194+ Str("session_id", string(input.SessionID)).
195+ Int("attempts", maxAttempts).
196+ Msg("snapshot save retries exhausted after transient fk violation")
197+ }
198+ return err
199+ }
200+
201+ log.Warn().
202+ Err(err).
203+ Str("session_id", string(input.SessionID)).
204+ Int("attempt", attempt).
205+ Int("max_attempts", maxAttempts).
206+ Dur("retry_delay", s.retryDelay).
207+ Msg("transient fk violation while saving snapshot; retrying")
208+
209+ if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
210+ return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
211+ }
212+ continue
213+ }
214+
215+ return nil
216+ }
217+
218+ return lastErr
219+}
220+
221+func (s *Service) markDirty() {
222+ s.mu.Lock()
223+ s.dirty = true
224+ s.mu.Unlock()
225+}
226+
227+func waitForRetry(ctx context.Context, delay time.Duration) error {
228+ if delay <= 0 {
229+ return nil
230+ }
231+ timer := time.NewTimer(delay)
232+ defer timer.Stop()
233+
234+ select {
235+ case <-ctx.Done():
236+ return ctx.Err()
237+ case <-timer.C:
238+ return nil
239+ }
240+}
241+
242+func isTransientFKError(err error) bool {
243+ // SQLite-specific transient FK detection:
244+ // we match case-insensitive "foreign key" text from wrapped driver errors.
245+ // Message wording may vary across SQLite versions/wrappers; adjust this check
246+ // if transient FK retries stop matching expected failures in production logs.
247+ if err == nil {
248+ return false
249+ }
250+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
251+ return false
252+ }
253+ return strings.Contains(strings.ToLower(err.Error()), "foreign key")
145 }254 }
A internal/infrastructure/snapshot/service_test.go +141 -0
@@ -0,0 +1,141 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type testProvider struct {
18+ sessionID entity.SessionID
19+ tabList *entity.TabList
20+}
21+
22+func (p *testProvider) GetTabList() *entity.TabList {
23+ return p.tabList
24+}
25+
26+func (p *testProvider) GetSessionID() entity.SessionID {
27+ return p.sessionID
28+}
29+
30+func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
31+ repo := repomocks.NewMockSessionStateRepository(t)
32+ calls := 0
33+ repo.EXPECT().
34+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
36+ calls++
37+ if calls == 1 {
38+ return errors.New("FOREIGN KEY constraint failed")
39+ }
40+ return nil
41+ })
42+
43+ uc := usecase.NewSnapshotSessionUseCase(repo)
44+ svc := NewService(uc, &testProvider{
45+ sessionID: "20260207_120000_fk_retry_ok",
46+ tabList: entity.NewTabList(),
47+ }, 1)
48+ svc.retryDelay = time.Millisecond
49+ svc.ready = true
50+ svc.dirty = true
51+
52+ err := svc.saveSnapshot(context.Background())
53+ require.NoError(t, err)
54+ assert.Equal(t, 2, calls)
55+ assert.False(t, svc.dirty)
56+}
57+
58+func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
59+ repo := repomocks.NewMockSessionStateRepository(t)
60+ calls := 0
61+ fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed")
62+ repo.EXPECT().
63+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
64+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
65+ calls++
66+ return fkErr
67+ })
68+
69+ uc := usecase.NewSnapshotSessionUseCase(repo)
70+ svc := NewService(uc, &testProvider{
71+ sessionID: "20260207_120000_fk_retry_fail",
72+ tabList: entity.NewTabList(),
73+ }, 1)
74+ svc.retryDelay = time.Millisecond
75+ svc.ready = true
76+ svc.dirty = true
77+
78+ err := svc.saveSnapshot(context.Background())
79+ require.Error(t, err)
80+ require.ErrorIs(t, err, fkErr)
81+ assert.Equal(t, svc.retries+1, calls)
82+ assert.True(t, svc.dirty)
83+}
84+
85+func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
86+ repo := repomocks.NewMockSessionStateRepository(t)
87+ calls := 0
88+ nonFKErr := errors.New("database is read-only")
89+ repo.EXPECT().
90+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
91+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
92+ calls++
93+ return nonFKErr
94+ })
95+
96+ uc := usecase.NewSnapshotSessionUseCase(repo)
97+ svc := NewService(uc, &testProvider{
98+ sessionID: "20260207_120000_non_fk",
99+ tabList: entity.NewTabList(),
100+ }, 1)
101+ svc.retryDelay = time.Millisecond
102+ svc.ready = true
103+ svc.dirty = true
104+
105+ err := svc.saveSnapshot(context.Background())
106+ require.Error(t, err)
107+ require.ErrorIs(t, err, nonFKErr)
108+ assert.Equal(t, 1, calls)
109+ assert.True(t, svc.dirty)
110+}
111+
112+func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
113+ repo := repomocks.NewMockSessionStateRepository(t)
114+ saved := make(chan struct{}, 1)
115+ repo.EXPECT().
116+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
117+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
118+ saved <- struct{}{}
119+ return nil
120+ })
121+
122+ uc := usecase.NewSnapshotSessionUseCase(repo)
123+ svc := NewService(uc, &testProvider{
124+ sessionID: "20260207_120000_ready_flush",
125+ tabList: entity.NewTabList(),
126+ }, 1)
127+ svc.Start(context.Background())
128+ svc.dirty = true
129+
130+ svc.SetReady()
131+
132+ select {
133+ case <-saved:
134+ case <-time.After(500 * time.Millisecond):
135+ t.Fatal("expected pending snapshot to be saved after SetReady")
136+ }
137+ svc.mu.Lock()
138+ dirty := svc.dirty
139+ svc.mu.Unlock()
140+ assert.False(t, dirty)
141+}
M internal/infrastructure/updater/github.go +176 -7
@@ -12,6 +12,8 @@ import (
12 "errors"12 "errors"
13 "fmt"13 "fmt"
14 "io"14 "io"
15+ "math/rand"
16+ "net"
15 "net/http"17 "net/http"
16 "net/url"18 "net/url"
17 "os"19 "os"
@@ -20,8 +22,10 @@ import (
20 "runtime"22 "runtime"
21 "strconv"23 "strconv"
22 "strings"24 "strings"
25+ "syscall"
23 "time"26 "time"
24 27
28+ "github.com/bnema/dumber/internal/application/port"
25 "github.com/bnema/dumber/internal/domain/entity"29 "github.com/bnema/dumber/internal/domain/entity"
26 "github.com/bnema/dumber/internal/infrastructure/env"30 "github.com/bnema/dumber/internal/infrastructure/env"
27 "github.com/bnema/dumber/internal/logging"31 "github.com/bnema/dumber/internal/logging"
@@ -58,6 +62,18 @@ const (
58 62
59 // Allowed GitHub download host.63 // Allowed GitHub download host.
60 allowedDownloadHost = "github.com"64 allowedDownloadHost = "github.com"
65+
66+ // Maximum number of attempts for retryable network requests.
67+ maxRetryAttempts = 3
68+
69+ // Base delay used for exponential backoff between retries.
70+ retryBaseDelay = 250 * time.Millisecond
71+
72+ // Maximum delay cap for exponential backoff between retries.
73+ retryMaxDelay = 2 * time.Second
74+
75+ // Max random jitter added to each retry backoff.
76+ retryJitterMax = 200 * time.Millisecond
61 )77 )
62 78
63 // githubRelease represents the GitHub API response for a release.79 // githubRelease represents the GitHub API response for a release.
@@ -70,13 +86,17 @@ type githubRelease struct {
70 86
71 // GitHubChecker implements UpdateChecker using the GitHub API.87 // GitHubChecker implements UpdateChecker using the GitHub API.
72 type GitHubChecker struct {88 type GitHubChecker struct {
73- client *http.Client89+ client *http.Client
90+ randInt63 func(n int64) int64
91+ sleep func(ctx context.Context, d time.Duration) error
74 }92 }
75 93
76 // NewGitHubChecker creates a new GitHub-based update checker.94 // NewGitHubChecker creates a new GitHub-based update checker.
77 func NewGitHubChecker() *GitHubChecker {95 func NewGitHubChecker() *GitHubChecker {
78 return &GitHubChecker{96 return &GitHubChecker{
79- client: &http.Client{Timeout: apiTimeout},97+ client: &http.Client{Timeout: apiTimeout},
98+ randInt63: rand.Int63n,
99+ sleep: waitForBackoff,
80 }100 }
81 }101 }
82 102
@@ -115,13 +135,21 @@ func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion strin
115 req.Header.Set("Accept", "application/vnd.github.v3+json")135 req.Header.Set("Accept", "application/vnd.github.v3+json")
116 req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)136 req.Header.Set("User-Agent", "dumber-browser/"+currentVersion)
117 137
118- resp, err := g.client.Do(req)138+ resp, err := g.doRequestWithRetry(ctx, req)
119 if err != nil {139 if err != nil {
140+ if isRetryableRequestError(err) {
141+ log.Debug().Err(err).Msg("transient update check failure; skipping check")
142+ return nil, fmt.Errorf("%w: %w", port.ErrUpdateCheckTransient, err)
143+ }
120 return nil, fmt.Errorf("failed to fetch latest release: %w", err)144 return nil, fmt.Errorf("failed to fetch latest release: %w", err)
121 }145 }
122 defer func() { _ = resp.Body.Close() }()146 defer func() { _ = resp.Body.Close() }()
123 147
124 if resp.StatusCode != http.StatusOK {148 if resp.StatusCode != http.StatusOK {
149+ if isRetryableStatus(resp.StatusCode) {
150+ log.Debug().Int("status", resp.StatusCode).Msg("transient update check API status; skipping check")
151+ return nil, fmt.Errorf("%w: github API returned status %d", port.ErrUpdateCheckTransient, resp.StatusCode)
152+ }
125 return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)153 return nil, fmt.Errorf("github API returned status %d", resp.StatusCode)
126 }154 }
127 155
@@ -214,16 +242,153 @@ func compareVersions(v1, v2 string) int {
214 242
215 // GitHubDownloader implements UpdateDownloader for GitHub releases.243 // GitHubDownloader implements UpdateDownloader for GitHub releases.
216 type GitHubDownloader struct {244 type GitHubDownloader struct {
217- client *http.Client245+ client *http.Client
246+ randInt63 func(n int64) int64
247+ sleep func(ctx context.Context, d time.Duration) error
218 }248 }
219 249
220 // NewGitHubDownloader creates a new GitHub release downloader.250 // NewGitHubDownloader creates a new GitHub release downloader.
221 func NewGitHubDownloader() *GitHubDownloader {251 func NewGitHubDownloader() *GitHubDownloader {
222 return &GitHubDownloader{252 return &GitHubDownloader{
223- client: &http.Client{Timeout: downloadTimeout},253+ client: &http.Client{Timeout: downloadTimeout},
254+ randInt63: rand.Int63n,
255+ sleep: waitForBackoff,
256+ }
257+}
258+
259+func isRetryableStatus(status int) bool {
260+ if status == http.StatusForbidden || status == http.StatusTooManyRequests || status == http.StatusRequestTimeout {
261+ return true
262+ }
263+ return status >= http.StatusInternalServerError
264+}
265+
266+func isRetryableRequestError(err error) bool {
267+ if err == nil {
268+ return false
269+ }
270+ if errors.Is(err, context.Canceled) {
271+ return false
272+ }
273+ if errors.Is(err, context.DeadlineExceeded) {
274+ return true
275+ }
276+
277+ var netErr net.Error
278+ if errors.As(err, &netErr) {
279+ if netErr.Timeout() {
280+ return true
281+ }
282+ //nolint:staticcheck // net.Error.Temporary is deprecated but still useful for transient detection
283+ if netErr.Temporary() {
284+ return true
285+ }
286+ }
287+
288+ var opErr *net.OpError
289+ if errors.As(err, &opErr) {
290+ if isTransientSyscallError(opErr.Err) {
291+ return true
292+ }
293+ }
294+
295+ return false
296+}
297+
298+func isTransientSyscallError(err error) bool {
299+ var errno syscall.Errno
300+ if !errors.As(err, &errno) {
301+ return false
302+ }
303+ switch errno {
304+ case syscall.ECONNRESET, syscall.ECONNREFUSED,
305+ syscall.EADDRNOTAVAIL, syscall.ENETUNREACH,
306+ syscall.EHOSTUNREACH:
307+ return true
308+ }
309+ return false
310+}
311+
312+func waitForBackoff(ctx context.Context, d time.Duration) error {
313+ timer := time.NewTimer(d)
314+ defer timer.Stop()
315+
316+ select {
317+ case <-ctx.Done():
318+ return ctx.Err()
319+ case <-timer.C:
320+ return nil
224 }321 }
225 }322 }
226 323
324+func retryDelayForAttempt(attempt int, randInt63 func(n int64) int64) time.Duration {
325+ if attempt < 1 {
326+ attempt = 1
327+ }
328+ delay := retryBaseDelay
329+ for i := 1; i < attempt; i++ {
330+ if delay >= retryMaxDelay {
331+ delay = retryMaxDelay
332+ break
333+ }
334+ delay *= 2
335+ if delay > retryMaxDelay {
336+ delay = retryMaxDelay
337+ }
338+ }
339+
340+ if randInt63 != nil && retryJitterMax > 0 {
341+ delay += time.Duration(randInt63(int64(retryJitterMax)))
342+ }
343+
344+ if delay > retryMaxDelay {
345+ delay = retryMaxDelay
346+ }
347+
348+ return delay
349+}
350+
351+// doRequestWithRetryHelper retries the same request instance across attempts.
352+// It assumes req.Body is non-consumable (for example http.NoBody); it does not clone
353+// the request or rewind req.Body. Callers needing retries with a real body must provide
354+// a fresh cloned request per attempt or use a rewindable body implementation.
355+func doRequestWithRetryHelper(
356+ ctx context.Context,
357+ client *http.Client,
358+ req *http.Request,
359+ sleep func(ctx context.Context, d time.Duration) error,
360+ randInt63 func(n int64) int64,
361+) (*http.Response, error) {
362+ for attempt := 1; ; attempt++ {
363+ if attempt > maxRetryAttempts {
364+ return nil, fmt.Errorf("request failed after retries")
365+ }
366+ resp, err := client.Do(req)
367+ if err != nil {
368+ if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
369+ return nil, err
370+ }
371+ if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
372+ return nil, waitErr
373+ }
374+ continue
375+ }
376+
377+ if !isRetryableStatus(resp.StatusCode) || attempt == maxRetryAttempts {
378+ return resp, nil
379+ }
380+
381+ _ = resp.Body.Close()
382+ if waitErr := sleep(ctx, retryDelayForAttempt(attempt, randInt63)); waitErr != nil {
383+ return nil, waitErr
384+ }
385+ }
386+}
387+
388+func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
389+ return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
390+}
391+
227 // validateDownloadURL ensures the URL is a valid GitHub releases URL.392 // validateDownloadURL ensures the URL is a valid GitHub releases URL.
228 func validateDownloadURL(downloadURL string) error {393 func validateDownloadURL(downloadURL string) error {
229 parsed, err := url.Parse(downloadURL)394 parsed, err := url.Parse(downloadURL)
@@ -257,7 +422,7 @@ func (g *GitHubDownloader) fetchChecksums(ctx context.Context) (map[string]strin
257 }422 }
258 req.Header.Set("User-Agent", "dumber-browser")423 req.Header.Set("User-Agent", "dumber-browser")
259 424
260- resp, err := g.client.Do(req)425+ resp, err := g.doRequestWithRetry(ctx, req)
261 if err != nil {426 if err != nil {
262 return nil, fmt.Errorf("failed to fetch checksums: %w", err)427 return nil, fmt.Errorf("failed to fetch checksums: %w", err)
263 }428 }
@@ -362,7 +527,7 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
362 527
363 log.Debug().Str("url", downloadURL).Msg("downloading update")528 log.Debug().Str("url", downloadURL).Msg("downloading update")
364 529
365- resp, err := g.client.Do(req)530+ resp, err := g.doRequestWithRetry(ctx, req)
366 if err != nil {531 if err != nil {
367 return "", fmt.Errorf("failed to download: %w", err)532 return "", fmt.Errorf("failed to download: %w", err)
368 }533 }
@@ -423,6 +588,10 @@ func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir st
423 return archivePath, nil588 return archivePath, nil
424 }589 }
425 590
591+func (g *GitHubDownloader) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
592+ return doRequestWithRetryHelper(ctx, g.client, req, g.sleep, g.randInt63)
593+}
594+
426 // sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.595 // sanitizeTarPath validates and sanitizes a tar header name to prevent path traversal.
427 func sanitizeTarPath(name, destDir string) (string, error) {596 func sanitizeTarPath(name, destDir string) (string, error) {
428 // Clean the path to remove any . or .. components.597 // Clean the path to remove any . or .. components.
M internal/infrastructure/updater/github_test.go +141 -0
@@ -1,12 +1,20 @@
1 package updater1 package updater
2 2
3 import (3 import (
4+ "context"
4 "crypto/sha256"5 "crypto/sha256"
5 "encoding/hex"6 "encoding/hex"
7+ "errors"
8+ "io"
9+ "net/http"
6 "os"10 "os"
7 "path/filepath"11 "path/filepath"
8 "strings"12 "strings"
13+ "sync/atomic"
9 "testing"14 "testing"
15+ "time"
16+
17+ "github.com/bnema/dumber/internal/application/port"
10 )18 )
11 19
12 func TestCompareVersions(t *testing.T) {20 func TestCompareVersions(t *testing.T) {
@@ -355,7 +363,140 @@ func TestSizeLimits(t *testing.T) {
355 }363 }
356 }364 }
357 365
366+func TestRetryDelayForAttempt(t *testing.T) {
367+ tests := []struct {
368+ name string
369+ attempt int
370+ want time.Duration
371+ }{
372+ {name: "first attempt", attempt: 1, want: retryBaseDelay},
373+ {name: "second attempt", attempt: 2, want: retryBaseDelay * 2},
374+ {name: "third attempt", attempt: 3, want: retryBaseDelay * 4},
375+ {name: "capped attempt", attempt: 6, want: retryMaxDelay},
376+ }
377+
378+ for _, tt := range tests {
379+ t.Run(tt.name, func(t *testing.T) {
380+ got := retryDelayForAttempt(tt.attempt, func(_ int64) int64 { return 0 })
381+ if got != tt.want {
382+ t.Fatalf("retryDelayForAttempt(%d) = %v, want %v", tt.attempt, got, tt.want)
383+ }
384+ })
385+ }
386+}
387+
388+func TestRetryDelayForAttemptAddsJitter(t *testing.T) {
389+ got := retryDelayForAttempt(1, func(_ int64) int64 { return int64(50 * time.Millisecond) })
390+ want := retryBaseDelay + (50 * time.Millisecond)
391+ if got != want {
392+ t.Fatalf("retryDelayForAttempt with jitter = %v, want %v", got, want)
393+ }
394+}
395+
396+func TestGitHubCheckerDoRequestWithRetry_RetryableStatus(t *testing.T) {
397+ var calls int32
398+ tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
399+ attempt := atomic.AddInt32(&calls, 1)
400+ if attempt < maxRetryAttempts {
401+ return testResponse(http.StatusForbidden, "forbidden"), nil
402+ }
403+ return testResponse(http.StatusOK, `{"tag_name":"v1.2.3"}`), nil
404+ })
405+
406+ checker := NewGitHubChecker()
407+ checker.client = &http.Client{Transport: tr}
408+ checker.randInt63 = func(_ int64) int64 { return 0 }
409+ checker.sleep = func(context.Context, time.Duration) error { return nil }
410+
411+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, githubAPIURL, http.NoBody)
412+ if err != nil {
413+ t.Fatalf("failed to create request: %v", err)
414+ }
415+
416+ resp, err := checker.doRequestWithRetry(context.Background(), req)
417+ if err != nil {
418+ t.Fatalf("doRequestWithRetry() unexpected error: %v", err)
419+ }
420+ defer func() { _ = resp.Body.Close() }()
421+
422+ if resp.StatusCode != http.StatusOK {
423+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
424+ }
425+ if atomic.LoadInt32(&calls) != maxRetryAttempts {
426+ t.Fatalf("calls = %d, want %d", calls, maxRetryAttempts)
427+ }
428+}
429+
430+func TestGitHubCheckerCheckForUpdate_TransientError(t *testing.T) {
431+ var calls int32
432+ tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
433+ atomic.AddInt32(&calls, 1)
434+ return testResponse(http.StatusForbidden, "forbidden"), nil
435+ })
436+
437+ checker := NewGitHubChecker()
438+ checker.client = &http.Client{Transport: tr}
439+ checker.randInt63 = func(_ int64) int64 { return 0 }
440+ checker.sleep = func(context.Context, time.Duration) error { return nil }
441+
442+ _, err := checker.CheckForUpdate(context.Background(), "1.0.0")
443+ if err == nil {
444+ t.Fatal("expected error, got nil")
445+ }
446+ if !errors.Is(err, port.ErrUpdateCheckTransient) {
447+ t.Fatalf("expected transient error, got %v", err)
448+ }
449+ if atomic.LoadInt32(&calls) != maxRetryAttempts {
450+ t.Fatalf("calls = %d, want %d", calls, maxRetryAttempts)
451+ }
452+}
453+
454+func TestGitHubDownloaderDoRequestWithRetry_NoRetryOnClientError(t *testing.T) {
455+ var calls int32
456+ tr := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
457+ atomic.AddInt32(&calls, 1)
458+ return testResponse(http.StatusNotFound, "not found"), nil
459+ })
460+
461+ downloader := NewGitHubDownloader()
462+ downloader.client = &http.Client{Transport: tr}
463+ downloader.randInt63 = func(_ int64) int64 { return 0 }
464+ downloader.sleep = func(context.Context, time.Duration) error { return nil }
465+
466+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, checksumsURLTemplate, http.NoBody)
467+ if err != nil {
468+ t.Fatalf("failed to create request: %v", err)
469+ }
470+
471+ resp, err := downloader.doRequestWithRetry(context.Background(), req)
472+ if err != nil {
473+ t.Fatalf("doRequestWithRetry() unexpected error: %v", err)
474+ }
475+ defer func() { _ = resp.Body.Close() }()
476+
477+ if resp.StatusCode != http.StatusNotFound {
478+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
479+ }
480+ if atomic.LoadInt32(&calls) != 1 {
481+ t.Fatalf("calls = %d, want 1", calls)
482+ }
483+}
484+
358 // contains checks if substr is in s (helper for error message checks).485 // contains checks if substr is in s (helper for error message checks).
359 func contains(s, substr string) bool {486 func contains(s, substr string) bool {
360 return strings.Contains(s, substr)487 return strings.Contains(s, substr)
361 }488 }
489+
490+type roundTripFunc func(*http.Request) (*http.Response, error)
491+
492+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
493+ return f(req)
494+}
495+
496+func testResponse(status int, body string) *http.Response {
497+ return &http.Response{
498+ StatusCode: status,
499+ Body: io.NopCloser(strings.NewReader(body)),
500+ Header: make(http.Header),
501+ }
502+}
M internal/infrastructure/webkit/context.go +25 -6
@@ -37,8 +37,10 @@ type WebKitContext struct {
37 // This MUST be called before creating any WebViews to ensure they use persistent storage.37 // This MUST be called before creating any WebViews to ensure they use persistent storage.
38 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {38 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
39 return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{39 return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{
40- DataDir: dataDir,40+ DataDir: dataDir,
41- CacheDir: cacheDir,41+ CacheDir: cacheDir,
42+ CookiePolicy: port.WebKitCookiePolicyNoThirdParty,
43+ ITPEnabled: true,
42 })44 })
43 }45 }
44 46
@@ -71,7 +73,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
71 73
72 // Create persistent network session FIRST74 // Create persistent network session FIRST
73 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"75 // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
74- if err := wkCtx.initNetworkSession(); err != nil {76+ if err := wkCtx.initNetworkSession(opts); err != nil {
75 return nil, fmt.Errorf("failed to init network session: %w", err)77 return nil, fmt.Errorf("failed to init network session: %w", err)
76 }78 }
77 79
@@ -112,7 +114,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
112 }114 }
113 115
114 // initNetworkSession creates and configures the persistent network session.116 // initNetworkSession creates and configures the persistent network session.
115-func (c *WebKitContext) initNetworkSession() error {117+func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error {
116 // Create persistent network session118 // Create persistent network session
117 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)119 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
118 if session == nil {120 if session == nil {
@@ -158,10 +160,14 @@ func (c *WebKitContext) initNetworkSession() error {
158 160
159 cookiePath := filepath.Join(c.dataDir, "cookies.db")161 cookiePath := filepath.Join(c.dataDir, "cookies.db")
160 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)162 cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)
161- cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdPartyValue)163+ cookiePolicy, cookiePolicyLabel := mapCookiePolicy(opts.CookiePolicy)
164+ cookieManager.SetAcceptPolicy(cookiePolicy)
165+ session.SetItpEnabled(opts.ITPEnabled)
162 166
163- c.logger.Debug().167+ c.logger.Info().
164 Str("cookie_path", cookiePath).168 Str("cookie_path", cookiePath).
169+ Str("cookie_policy", cookiePolicyLabel).
170+ Bool("itp_enabled", opts.ITPEnabled).
165 Msg("cookie storage configured")171 Msg("cookie storage configured")
166 172
167 // Enable persistent credential storage173 // Enable persistent credential storage
@@ -182,6 +188,19 @@ func (c *WebKitContext) initNetworkSession() error {
182 return nil188 return nil
183 }189 }
184 190
191+func mapCookiePolicy(policy port.WebKitCookiePolicy) (webkit.CookieAcceptPolicy, string) {
192+ switch policy {
193+ case port.WebKitCookiePolicyAlways:
194+ return webkit.CookiePolicyAcceptAlwaysValue, string(port.WebKitCookiePolicyAlways)
195+ case port.WebKitCookiePolicyNever:
196+ return webkit.CookiePolicyAcceptNeverValue, string(port.WebKitCookiePolicyNever)
197+ case port.WebKitCookiePolicyNoThirdParty, "":
198+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
199+ default:
200+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
201+ }
202+}
203+
185 // Context returns the shared WebContext.204 // Context returns the shared WebContext.
186 func (c *WebKitContext) Context() *webkit.WebContext {205 func (c *WebKitContext) Context() *webkit.WebContext {
187 c.mu.RLock()206 c.mu.RLock()
M internal/infrastructure/webkit/messaging.go +4 -4
@@ -47,7 +47,7 @@ type MessageRouter struct {
47 47
48 mu sync.RWMutex48 mu sync.RWMutex
49 callbacks []interface{}49 callbacks []interface{}
50- signals []uint3250+ signals []uint
51 51
52 idMu sync.Mutex52 idMu sync.Mutex
53 syncedIDs map[WebViewID]bool53 syncedIDs map[WebViewID]bool
@@ -121,7 +121,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal
121 // It registers the script message handler in the MAIN world (not isolated).121 // It registers the script message handler in the MAIN world (not isolated).
122 // WebKit's messageHandlers is only available in main world.122 // WebKit's messageHandlers is only available in main world.
123 // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.123 // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
124-func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint32, error) {124+func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) {
125 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()125 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
126 126
127 log.Debug().Msg("SetupMessageHandler called")127 log.Debug().Msg("SetupMessageHandler called")
@@ -146,7 +146,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
146 146
147 // Connect to "script-message-received::dumber" with handler name as signal detail147 // Connect to "script-message-received::dumber" with handler name as signal detail
148 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)148 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
149- log.Debug().Uint32("signal_id", signalID).Str("handler", MessageHandlerName).Msg("signal connected with detail")149+ log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
150 150
151 r.mu.Lock()151 r.mu.Lock()
152 r.signals = append(r.signals, signalID)152 r.signals = append(r.signals, signalID)
@@ -164,7 +164,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
164 log.Info().164 log.Info().
165 Str("handler", MessageHandlerName).165 Str("handler", MessageHandlerName).
166 Str("world", "main").166 Str("world", "main").
167- Uint32("signal_id", signalID).167+ Uint64("signal_id", uint64(signalID)).
168 Msg("script message handler connected")168 Msg("script message handler connected")
169 169
170 return signalID, nil170 return signalID, nil
M internal/infrastructure/webkit/pool.go +182 -39
@@ -3,6 +3,8 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "os"
7+ "strings"
6 "sync"8 "sync"
7 "sync/atomic"9 "sync/atomic"
8 "time"10 "time"
@@ -31,6 +33,12 @@ type PoolConfig struct {
31 PrewarmCount int33 PrewarmCount int
32 // IdleTimeout is how long idle views stay in the pool before being destroyed.34 // IdleTimeout is how long idle views stay in the pool before being destroyed.
33 IdleTimeout time.Duration35 IdleTimeout time.Duration
36+ // ReusePolicy controls if/when released WebViews are returned to pool.
37+ // Values: off, safe, aggressive.
38+ ReusePolicy string
39+ // TerminatePolicy controls whether Destroy() should terminate web process.
40+ // Values: auto, always, never.
41+ TerminatePolicy string
34 }42 }
35 43
36 // DefaultPoolConfig returns sensible defaults for the pool.44 // DefaultPoolConfig returns sensible defaults for the pool.
@@ -43,6 +51,12 @@ func DefaultPoolConfig() PoolConfig {
43 }51 }
44 }52 }
45 53
54+const (
55+ reusePolicyOff = "off"
56+ reusePolicySafe = "safe"
57+ reusePolicyAggressive = "aggressive"
58+)
59+
46 // WebViewPool manages a pool of pre-created WebViews for fast tab creation.60 // WebViewPool manages a pool of pre-created WebViews for fast tab creation.
47 type WebViewPool struct {61 type WebViewPool struct {
48 wkCtx *WebKitContext62 wkCtx *WebKitContext
@@ -50,6 +64,7 @@ type WebViewPool struct {
50 config PoolConfig64 config PoolConfig
51 65
52 pool chan *WebView66 pool chan *WebView
67+ poolMu sync.Mutex
53 injector *ContentInjector68 injector *ContentInjector
54 router *MessageRouter69 router *MessageRouter
55 filterApplier FilterApplier // Optional content filter applier70 filterApplier FilterApplier // Optional content filter applier
@@ -59,6 +74,19 @@ type WebViewPool struct {
59 74
60 closed atomic.Bool75 closed atomic.Bool
61 wg sync.WaitGroup76 wg sync.WaitGroup
77+
78+ reusePolicy string
79+ terminatePolicy string
80+
81+ acquireCount atomic.Uint64
82+ acquirePoolHits atomic.Uint64
83+ acquirePoolMisses atomic.Uint64
84+ releaseCount atomic.Uint64
85+ releaseReused atomic.Uint64
86+ releaseDestroyed atomic.Uint64
87+ releaseDropped atomic.Uint64
88+ releaseTotalNanos atomic.Int64
89+ acquireTotalNanos atomic.Int64
62 }90 }
63 91
64 // NewWebViewPool creates a new WebView pool.92 // NewWebViewPool creates a new WebView pool.
@@ -79,21 +107,28 @@ func NewWebViewPool(
79 if cfg.MinSize > cfg.MaxSize {107 if cfg.MinSize > cfg.MaxSize {
80 cfg.MinSize = cfg.MaxSize108 cfg.MinSize = cfg.MaxSize
81 }109 }
110+ cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy)
111+ cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy)
82 if ctx == nil {112 if ctx == nil {
83 ctx = context.Background()113 ctx = context.Background()
84 }114 }
85 115
86 p := &WebViewPool{116 p := &WebViewPool{
87- wkCtx: wkCtx,117+ wkCtx: wkCtx,
88- settings: settings,118+ settings: settings,
89- config: cfg,119+ config: cfg,
90- pool: make(chan *WebView, cfg.MaxSize),120+ pool: make(chan *WebView, cfg.MaxSize),
91- injector: injector,121+ injector: injector,
92- router: router,122+ router: router,
123+ reusePolicy: cfg.ReusePolicy,
124+ terminatePolicy: cfg.TerminatePolicy,
93 }125 }
94 126
95 log := logging.FromContext(ctx)127 log := logging.FromContext(ctx)
96- log.Debug().Msg("creating webview pool")128+ log.Debug().
129+ Str("reuse_policy", p.reusePolicy).
130+ Str("terminate_policy", p.terminatePolicy).
131+ Msg("creating webview pool")
97 132
98 return p133 return p
99 }134 }
@@ -113,6 +148,7 @@ func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
113 // Acquire gets a WebView from the pool or creates a new one.148 // Acquire gets a WebView from the pool or creates a new one.
114 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {149 func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
115 log := logging.FromContext(ctx)150 log := logging.FromContext(ctx)
151+ start := time.Now()
116 152
117 if p.closed.Load() {153 if p.closed.Load() {
118 return nil, ErrPoolClosed154 return nil, ErrPoolClosed
@@ -135,7 +171,15 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
135 if p.filterApplier != nil {171 if p.filterApplier != nil {
136 p.filterApplier.ApplyTo(ctx, wv.ucm)172 p.filterApplier.ApplyTo(ctx, wv.ucm)
137 }173 }
138- log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")174+ p.acquireCount.Add(1)
175+ p.acquirePoolHits.Add(1)
176+ p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
177+ log.Debug().
178+ Uint64("id", uint64(wv.ID())).
179+ Dur("latency", time.Since(start)).
180+ Int("pool_size", len(p.pool)).
181+ Str("source", "pool").
182+ Msg("acquired webview")
139 return wv, nil183 return wv, nil
140 }184 }
141 // Fall through to create new if the pooled one was destroyed185 // Fall through to create new if the pooled one was destroyed
@@ -145,7 +189,28 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
145 189
146 // Pool empty or pooled view was destroyed, create new190 // Pool empty or pooled view was destroyed, create new
147 log.Debug().Msg("pool empty, creating new webview")191 log.Debug().Msg("pool empty, creating new webview")
148- return p.createWebView(ctx)192+ wv, err := p.createWebView(ctx)
193+ if err != nil {
194+ return nil, err
195+ }
196+ p.acquireCount.Add(1)
197+ p.acquirePoolMisses.Add(1)
198+ p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
199+
200+ // Re-check after creation: if pool closed during createWebView, destroy
201+ // the new WebView and return an error to avoid leaking resources.
202+ if p.closed.Load() {
203+ wv.DestroyWithPolicy(p.terminatePolicy)
204+ return nil, ErrPoolClosed
205+ }
206+
207+ log.Debug().
208+ Uint64("id", uint64(wv.ID())).
209+ Dur("latency", time.Since(start)).
210+ Int("pool_size", len(p.pool)).
211+ Str("source", "new").
212+ Msg("acquired webview")
213+ return wv, nil
149 }214 }
150 215
151 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {216 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
@@ -174,29 +239,52 @@ func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
174 return wv, nil239 return wv, nil
175 }240 }
176 241
177-// Release returns a WebView to the pool.242+// Release returns a WebView to the pool when configured and safe.
178-// For safety, we always destroy and never pool WebViews that have been used.243+// Default behavior remains conservative (`reuse_policy=off`) and destroys on release.
179-// This prevents crashes from GLib signals firing after the widget tree is modified.
180-// The pool is only used for prewarmed WebViews that haven't been attached to UI yet.
181 func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {244 func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
182 log := logging.FromContext(ctx)245 log := logging.FromContext(ctx)
246+ start := time.Now()
183 247
184 if wv == nil || wv.IsDestroyed() || p.closed.Load() {248 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
185 if wv != nil && !wv.IsDestroyed() {249 if wv != nil && !wv.IsDestroyed() {
186- wv.Destroy()250+ wv.DestroyWithPolicy(p.terminatePolicy)
187 }251 }
252+ p.releaseDropped.Add(1)
188 return253 return
189 }254 }
190 255
191- // CRITICAL: Always destroy used WebViews instead of pooling them.256+ action := "destroyed"
192- // GLib signals are connected with closures that can fire asynchronously257+ if p.shouldReuseOnRelease(wv) {
193- // even after we clear Go callbacks. Loading about:blank triggers signals258+ wv.ResetForPoolReuse()
194- // that can crash if the widget tree has been modified.259+ if p.tryPut(ctx, wv) {
195- //260+ action = "reused"
196- // The pool is designed for prewarmed WebViews only, not for recycling261+ p.releaseReused.Add(1)
197- // WebViews that have been attached to the UI.262+ } else {
198- log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)")263+ action = "destroyed_after_reuse_failed"
199- wv.Destroy()264+ wv.DestroyWithPolicy(p.terminatePolicy)
265+ p.releaseDestroyed.Add(1)
266+ }
267+ } else {
268+ wv.DestroyWithPolicy(p.terminatePolicy)
269+ p.releaseDestroyed.Add(1)
270+ }
271+ p.releaseCount.Add(1)
272+ elapsed := time.Since(start)
273+ p.releaseTotalNanos.Add(elapsed.Nanoseconds())
274+ log.Debug().
275+ Uint64("id", uint64(wv.ID())).
276+ Str("action", action).
277+ Str("reuse_policy", p.reusePolicy).
278+ Str("terminate_policy", p.terminatePolicy).
279+ Dur("latency", elapsed).
280+ Int("pool_size", len(p.pool)).
281+ Uint64("acquire_total", p.acquireCount.Load()).
282+ Uint64("acquire_hits", p.acquirePoolHits.Load()).
283+ Uint64("acquire_misses", p.acquirePoolMisses.Load()).
284+ Uint64("release_total", p.releaseCount.Load()).
285+ Uint64("release_reused", p.releaseReused.Load()).
286+ Uint64("release_destroyed", p.releaseDestroyed.Load()).
287+ Msg("released webview")
200 }288 }
201 289
202 // PrewarmFirst creates exactly one WebView synchronously.290 // PrewarmFirst creates exactly one WebView synchronously.
@@ -218,12 +306,11 @@ func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
218 return err306 return err
219 }307 }
220 308
221- select {309+ if p.tryPut(ctx, wv) {
222- case p.pool <- wv:
223 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")310 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
224- default:311+ return nil
225- wv.Destroy() // Pool somehow full (shouldn't happen)
226 }312 }
313+ wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen)
227 return nil314 return nil
228 }315 }
229 316
@@ -294,11 +381,10 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
294 if err != nil {381 if err != nil {
295 log.Warn().Err(err).Msg("failed to prewarm webview")382 log.Warn().Err(err).Msg("failed to prewarm webview")
296 } else {383 } else {
297- select {384+ if p.tryPut(ctx, wv) {
298- case p.pool <- wv:
299 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")385 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
300- default:386+ } else {
301- wv.Destroy()387+ wv.DestroyWithPolicy(p.terminatePolicy)
302 }388 }
303 }389 }
304 390
@@ -336,12 +422,11 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
336 }422 }
337 423
338 // Try to add to pool424 // Try to add to pool
339- select {425+ if p.tryPut(ctx, wv) {
340- case p.pool <- wv:
341 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")426 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
342- default:427+ } else {
343 // Pool full, destroy428 // Pool full, destroy
344- wv.Destroy()429+ wv.DestroyWithPolicy(p.terminatePolicy)
345 }430 }
346 431
347 // Small delay between creations to avoid overwhelming the system432 // Small delay between creations to avoid overwhelming the system
@@ -387,11 +472,10 @@ refreshLoop:
387 }472 }
388 473
389 // Put back in pool474 // Put back in pool
390- select {475+ if p.tryPut(ctx, wv) {
391- case p.pool <- wv:
392 refreshed++476 refreshed++
393- default:477+ } else {
394- wv.Destroy()478+ wv.DestroyWithPolicy(p.terminatePolicy)
395 }479 }
396 default:480 default:
397 // Pool was modified during iteration, stop481 // Pool was modified during iteration, stop
@@ -417,7 +501,9 @@ func (p *WebViewPool) Close(ctx context.Context) {
417 p.wg.Wait()501 p.wg.Wait()
418 502
419 // Drain and destroy all pooled views503 // Drain and destroy all pooled views
504+ p.poolMu.Lock()
420 close(p.pool)505 close(p.pool)
506+ p.poolMu.Unlock()
421 for wv := range p.pool {507 for wv := range p.pool {
422 if wv != nil && !wv.IsDestroyed() {508 if wv != nil && !wv.IsDestroyed() {
423 wv.Destroy()509 wv.Destroy()
@@ -426,3 +512,60 @@ func (p *WebViewPool) Close(ctx context.Context) {
426 512
427 log.Debug().Msg("webview pool closed")513 log.Debug().Msg("webview pool closed")
428 }514 }
515+
516+func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
517+ if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() {
518+ return false
519+ }
520+ if len(p.pool) >= p.config.MaxSize {
521+ return false
522+ }
523+ switch p.reusePolicy {
524+ case reusePolicySafe:
525+ // Safe mode: only reuse views that haven't navigated, avoiding
526+ // back/forward/page context leaking across tabs.
527+ return !wv.HasNavigationActivity()
528+ case reusePolicyAggressive:
529+ // Aggressive mode: reuse regardless of navigation activity.
530+ // The view is reset via ResetForPoolReuse before being returned to the pool.
531+ return true
532+ default:
533+ return false
534+ }
535+}
536+
537+func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
538+ log := logging.FromContext(ctx)
539+ p.poolMu.Lock()
540+ defer p.poolMu.Unlock()
541+ if p.closed.Load() {
542+ return false
543+ }
544+ select {
545+ case p.pool <- wv:
546+ log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
547+ return true
548+ default:
549+ return false
550+ }
551+}
552+
553+func resolveReusePolicy(raw string) string {
554+ switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) {
555+ case reusePolicySafe:
556+ return reusePolicySafe
557+ case reusePolicyAggressive:
558+ return reusePolicyAggressive
559+ case reusePolicyOff, "":
560+ return reusePolicyOff
561+ default:
562+ return reusePolicyOff
563+ }
564+}
565+
566+func defaultIfEmpty(value, fallback string) string {
567+ if strings.TrimSpace(value) != "" {
568+ return value
569+ }
570+ return fallback
571+}
A internal/infrastructure/webkit/pool_policy_test.go +18 -0
@@ -0,0 +1,18 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestResolveReusePolicy(t *testing.T) {
10+ t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "")
11+ assert.Equal(t, reusePolicyOff, resolveReusePolicy(""))
12+ assert.Equal(t, reusePolicySafe, resolveReusePolicy("safe"))
13+ assert.Equal(t, reusePolicyAggressive, resolveReusePolicy("aggressive"))
14+ assert.Equal(t, reusePolicyOff, resolveReusePolicy("unknown"))
15+
16+ t.Setenv("DUMBER_WEBVIEW_REUSE_POLICY", "safe")
17+ assert.Equal(t, reusePolicySafe, resolveReusePolicy(""))
18+}
M internal/infrastructure/webkit/scheme_handler.go +157 -2
@@ -5,6 +5,7 @@ import (
5 "embed"5 "embed"
6 "encoding/json"6 "encoding/json"
7 "fmt"7 "fmt"
8+ "html"
8 "io/fs"9 "io/fs"
9 "mime"10 "mime"
10 "net/http"11 "net/http"
@@ -28,7 +29,9 @@ const (
28 HomePath = "home"29 HomePath = "home"
29 ConfigPath = "config"30 ConfigPath = "config"
30 ErrorPath = "error"31 ErrorPath = "error"
32+ CrashPath = "crash"
31 IndexHTML = "index.html"33 IndexHTML = "index.html"
34+ httpGET = "GET"
32 )35 )
33 36
34 // SchemeRequest represents a request to a custom URI scheme.37 // SchemeRequest represents a request to a custom URI scheme.
@@ -161,9 +164,22 @@ func (h *DumbSchemeHandler) registerDefaults() {
161 }164 }
162 }))165 }))
163 166
167+ // Crash page (web process termination fallback)
168+ h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
169+ if req.Method != "" && req.Method != httpGET {
170+ return nil
171+ }
172+ originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI))
173+ return &SchemeResponse{
174+ Data: []byte(buildCrashPageHTML(originalURI)),
175+ ContentType: "text/html; charset=utf-8",
176+ StatusCode: http.StatusOK,
177+ }
178+ }))
179+
164 // API: Get current config (used by dumb://config)180 // API: Get current config (used by dumb://config)
165 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
166- if req.Method != "GET" {182+ if req.Method != "" && req.Method != httpGET {
167 return nil183 return nil
168 }184 }
169 185
@@ -172,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
172 188
173 // API: Get default config (used by Reset Defaults in dumb://config)189 // API: Get default config (used by Reset Defaults in dumb://config)
174 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
175- if req.Method != "GET" {191+ if req.Method != "" && req.Method != httpGET {
176 return nil192 return nil
177 }193 }
178 194
@@ -180,6 +196,145 @@ func (h *DumbSchemeHandler) registerDefaults() {
180 }))196 }))
181 }197 }
182 198
199+func crashOriginalURI(requestURI string) string {
200+ if requestURI == "" {
201+ return ""
202+ }
203+ parsed, err := url.Parse(requestURI)
204+ if err != nil {
205+ return ""
206+ }
207+ return strings.TrimSpace(parsed.Query().Get("url"))
208+}
209+
210+func sanitizeCrashPageOriginalURI(originalURI string) string {
211+ originalURI = strings.TrimSpace(originalURI)
212+ if originalURI == "" {
213+ return ""
214+ }
215+ parsed, err := url.Parse(originalURI)
216+ if err != nil {
217+ return ""
218+ }
219+ switch strings.ToLower(parsed.Scheme) {
220+ case "http", "https":
221+ if parsed.Host == "" {
222+ return ""
223+ }
224+ return parsed.String()
225+ case "dumb":
226+ if parsed.Host == "" && parsed.Opaque == "" {
227+ return ""
228+ }
229+ return parsed.String()
230+ default:
231+ return ""
232+ }
233+}
234+
235+func buildCrashPageHTML(originalURI string) string {
236+ escapedURI := html.EscapeString(originalURI)
237+ return fmt.Sprintf(`<!DOCTYPE html>
238+<html lang="en">
239+<head>
240+ <meta charset="utf-8">
241+ <meta name="viewport" content="width=device-width, initial-scale=1">
242+ <title>Renderer crashed</title>
243+ <style>
244+ :root {
245+ color-scheme: dark;
246+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
247+ }
248+ body {
249+ margin: 0;
250+ min-height: 100vh;
251+ display: flex;
252+ align-items: center;
253+ justify-content: center;
254+ background: radial-gradient(circle at top, #253447, #101622 55%%);
255+ color: #f2f6fa;
256+ padding: 24px;
257+ }
258+ .card {
259+ width: min(640px, 100%%);
260+ background: rgba(10, 16, 26, 0.86);
261+ border: 1px solid rgba(144, 173, 205, 0.35);
262+ border-radius: 16px;
263+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
264+ padding: 28px;
265+ }
266+ h1 {
267+ margin: 0 0 12px;
268+ font-size: 1.8rem;
269+ }
270+ p {
271+ margin: 0 0 16px;
272+ line-height: 1.5;
273+ color: #c8d6e6;
274+ }
275+ .url {
276+ margin: 0 0 20px;
277+ padding: 12px;
278+ border-radius: 10px;
279+ background: rgba(26, 38, 56, 0.85);
280+ border: 1px solid rgba(139, 167, 194, 0.28);
281+ font-family: "IBM Plex Mono", "Fira Code", monospace;
282+ font-size: 0.92rem;
283+ overflow-wrap: anywhere;
284+ }
285+ .actions {
286+ display: flex;
287+ gap: 12px;
288+ flex-wrap: wrap;
289+ }
290+ button {
291+ border: 0;
292+ border-radius: 10px;
293+ padding: 10px 16px;
294+ cursor: pointer;
295+ font-size: 0.95rem;
296+ font-weight: 600;
297+ }
298+ .primary {
299+ background: #4dd0e1;
300+ color: #061018;
301+ }
302+ .secondary {
303+ background: #233346;
304+ color: #d6e5f5;
305+ }
306+ </style>
307+</head>
308+<body>
309+ <div class="card">
310+ <h1>Renderer process ended</h1>
311+ <p>The current page was interrupted. You can reload it to continue browsing.</p>
312+ <div class="url">%s</div>
313+ <div class="actions">
314+ <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
315+ <button class="secondary" id="stay-btn">Stay on this page</button>
316+ </div>
317+ </div>
318+ <script>
319+ const reloadButton = document.getElementById('reload-btn');
320+ const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
321+ reloadButton.addEventListener('click', function() {
322+ if (targetUrl) {
323+ window.location.href = targetUrl;
324+ return;
325+ }
326+ window.location.reload();
327+ });
328+ // "Stay on this page" keeps the crash page visible without navigating away.
329+ document.getElementById('stay-btn').addEventListener('click', function() {
330+ this.disabled = true;
331+ this.textContent = 'Staying on page';
332+ });
333+ </script>
334+</body>
335+</html>`, escapedURI, escapedURI)
336+}
337+
183 func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {338 func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
184 // Get hardware info for display and profile resolution339 // Get hardware info for display and profile resolution
185 // Use background context since survey results are cached and we don't want340 // 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 (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "net/url"6 "net/url"
7+ "os"
7 "strings"8 "strings"
8 "sync"9 "sync"
9 "sync/atomic"10 "sync/atomic"
@@ -135,39 +136,59 @@ type WebView struct {
135 lastProgressUpdate atomic.Int64 // Unix nanoseconds136 lastProgressUpdate atomic.Int64 // Unix nanoseconds
136 137
137 // Signal handler IDs for disconnection138 // Signal handler IDs for disconnection
138- signalIDs []uint32139+ signalIDs []uintptr
139 140
140 // Callbacks (set by UI layer)141 // Callbacks (set by UI layer)
141- OnLoadChanged func(LoadEvent)142+ OnLoadChanged func(LoadEvent)
142- OnTitleChanged func(string)143+ OnTitleChanged func(string)
143- OnURIChanged func(string)144+ OnURIChanged func(string)
144- OnProgressChanged func(float64)145+ OnProgressChanged func(float64)
145- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes146+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
146- OnClose func()147+ OnClose func()
147- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup148+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
148- OnReadyToShow func() // Called when popup is ready to display149+ OnReadyToShow func() // Called when popup is ready to display
149- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)150+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
150- OnEnterFullscreen func() bool // Return true to prevent fullscreen151+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
151- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen152+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
152- OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops153+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
153- OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)154+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
155+ OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
154 156
155 logger zerolog.Logger157 logger zerolog.Logger
156 mu sync.RWMutex158 mu sync.RWMutex
157 159
158 frontendAttached atomic.Bool160 frontendAttached atomic.Bool
161+ navigationActive atomic.Bool
159 162
160 // asyncCallbacks keeps references to async JS callbacks to prevent GC163 // asyncCallbacks keeps references to async JS callbacks to prevent GC
161 asyncCallbacks []interface{}164 asyncCallbacks []interface{}
162 165
166+ // runJSErrorStats aggregates repeated non-fatal RunJavaScript errors by domain+signature.
167+ runJSErrorStats map[string]runJSErrorStat
168+
163 // findController is cached to prevent GC from collecting the Go wrapper169 // findController is cached to prevent GC from collecting the Go wrapper
164 findController *findControllerAdapter170 findController *findControllerAdapter
165 findControllerOnce sync.Once171 findControllerOnce sync.Once
166 172
167 backForwardList *webkit.BackForwardList173 backForwardList *webkit.BackForwardList
168- backForwardListSignalID uint32174+ backForwardListSignalID uintptr
175+}
176+
177+type runJSErrorStat struct {
178+ count uint64
179+ lastLog time.Time
169 }180 }
170 181
182+const (
183+ terminatePolicyAuto = "auto"
184+ terminatePolicyAlways = "always"
185+ terminatePolicyNever = "never"
186+
187+ runJSNonFatalLogInterval = 30 * time.Second
188+ runJSAggregateLogEvery = 20
189+ runJSUnknown = "unknown"
190+)
191+
171 type findControllerAdapter struct {192 type findControllerAdapter struct {
172 fc *webkit.FindController193 fc *webkit.FindController
173 }194 }
@@ -234,7 +255,7 @@ func (a *findControllerAdapter) GetSearchText() string {
234 return a.fc.GetSearchText()255 return a.fc.GetSearchText()
235 }256 }
236 257
237-func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 {258+func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint {
238 if a == nil || a.fc == nil || callback == nil {259 if a == nil || a.fc == nil || callback == nil {
239 return 0260 return 0
240 }261 }
@@ -244,7 +265,7 @@ func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint
244 return a.fc.ConnectFoundText(&cb)265 return a.fc.ConnectFoundText(&cb)
245 }266 }
246 267
247-func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {268+func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint {
248 if a == nil || a.fc == nil || callback == nil {269 if a == nil || a.fc == nil || callback == nil {
249 return 0270 return 0
250 }271 }
@@ -254,7 +275,7 @@ func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
254 return a.fc.ConnectFailedToFindText(&cb)275 return a.fc.ConnectFailedToFindText(&cb)
255 }276 }
256 277
257-func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {278+func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint {
258 if a == nil || a.fc == nil || callback == nil {279 if a == nil || a.fc == nil || callback == nil {
259 return 0280 return 0
260 }281 }
@@ -264,7 +285,7 @@ func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint))
264 return a.fc.ConnectCountedMatches(&cb)285 return a.fc.ConnectCountedMatches(&cb)
265 }286 }
266 287
267-func (a *findControllerAdapter) DisconnectSignal(id uint32) {288+func (a *findControllerAdapter) DisconnectSignal(id uint) {
268 if a == nil || a.fc == nil || id == 0 {289 if a == nil || a.fc == nil || id == 0 {
269 return290 return
270 }291 }
@@ -300,10 +321,11 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
300 }321 }
301 322
302 wv := &WebView{323 wv := &WebView{
303- inner: inner,324+ inner: inner,
304- ucm: inner.GetUserContentManager(),325+ ucm: inner.GetUserContentManager(),
305- logger: log.With().Str("component", "webview").Logger(),326+ logger: log.With().Str("component", "webview").Logger(),
306- signalIDs: make([]uint32, 0, 4),327+ signalIDs: make([]uintptr, 0, 4),
328+ runJSErrorStats: make(map[string]runJSErrorStat),
307 }329 }
308 330
309 // Register in global registry331 // Register in global registry
@@ -349,11 +371,12 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
349 Msg("related webview created, checking pointers")371 Msg("related webview created, checking pointers")
350 372
351 wv := &WebView{373 wv := &WebView{
352- inner: inner,374+ inner: inner,
353- isRelated: true, // Shares web process with parent - must not terminate process on destroy375+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
354- ucm: inner.GetUserContentManager(),376+ ucm: inner.GetUserContentManager(),
355- logger: log.With().Str("component", "webview-popup").Logger(),377+ logger: log.With().Str("component", "webview-popup").Logger(),
356- signalIDs: make([]uint32, 0, 6),378+ signalIDs: make([]uintptr, 0, 6),
379+ runJSErrorStats: make(map[string]runJSErrorStat),
357 }380 }
358 381
359 wv.id = globalRegistry.register(wv)382 wv.id = globalRegistry.register(wv)
@@ -388,6 +411,7 @@ func (wv *WebView) connectSignals() {
388 wv.connectAudioStateSignal()411 wv.connectAudioStateSignal()
389 wv.connectMouseTargetChangedSignal()412 wv.connectMouseTargetChangedSignal()
390 wv.connectBackForwardListChangedSignal()413 wv.connectBackForwardListChangedSignal()
414+ wv.connectWebProcessTerminatedSignal()
391 }415 }
392 416
393 func (wv *WebView) connectLoadChangedSignal() {417 func (wv *WebView) connectLoadChangedSignal() {
@@ -404,6 +428,7 @@ func (wv *WebView) connectLoadChangedSignal() {
404 428
405 switch event {429 switch event {
406 case webkit.LoadStartedValue:430 case webkit.LoadStartedValue:
431+ wv.navigationActive.Store(true)
407 wv.isLoading = true432 wv.isLoading = true
408 wv.logger.Debug().Str("uri", uri).Msg("load started")433 wv.logger.Debug().Str("uri", uri).Msg("load started")
409 case webkit.LoadRedirectedValue:434 case webkit.LoadRedirectedValue:
@@ -421,7 +446,7 @@ func (wv *WebView) connectLoadChangedSignal() {
421 }446 }
422 }447 }
423 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)448 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
424- wv.signalIDs = append(wv.signalIDs, sigID)449+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
425 }450 }
426 451
427 func (wv *WebView) connectCloseSignal() {452 func (wv *WebView) connectCloseSignal() {
@@ -431,7 +456,7 @@ func (wv *WebView) connectCloseSignal() {
431 }456 }
432 }457 }
433 sigID := wv.inner.ConnectClose(&closeCb)458 sigID := wv.inner.ConnectClose(&closeCb)
434- wv.signalIDs = append(wv.signalIDs, sigID)459+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
435 }460 }
436 461
437 func (wv *WebView) connectCreateSignal() {462 func (wv *WebView) connectCreateSignal() {
@@ -473,7 +498,7 @@ func (wv *WebView) connectCreateSignal() {
473 return newWV.inner.Widget498 return newWV.inner.Widget
474 }499 }
475 sigID := wv.inner.ConnectCreate(&createCb)500 sigID := wv.inner.ConnectCreate(&createCb)
476- wv.signalIDs = append(wv.signalIDs, sigID)501+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
477 }502 }
478 503
479 func (wv *WebView) connectReadyToShowSignal() {504 func (wv *WebView) connectReadyToShowSignal() {
@@ -483,7 +508,7 @@ func (wv *WebView) connectReadyToShowSignal() {
483 }508 }
484 }509 }
485 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)510 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
486- wv.signalIDs = append(wv.signalIDs, sigID)511+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
487 }512 }
488 513
489 func (wv *WebView) connectTitleSignal() {514 func (wv *WebView) connectTitleSignal() {
@@ -498,7 +523,7 @@ func (wv *WebView) connectTitleSignal() {
498 }523 }
499 }524 }
500 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))525 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
501- wv.signalIDs = append(wv.signalIDs, sigID)526+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
502 }527 }
503 528
504 func (wv *WebView) connectURISignal() {529 func (wv *WebView) connectURISignal() {
@@ -514,7 +539,7 @@ func (wv *WebView) connectURISignal() {
514 }539 }
515 }540 }
516 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))541 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
517- wv.signalIDs = append(wv.signalIDs, sigID)542+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
518 }543 }
519 544
520 func (wv *WebView) connectFaviconSignal() {545 func (wv *WebView) connectFaviconSignal() {
@@ -525,7 +550,7 @@ func (wv *WebView) connectFaviconSignal() {
525 }550 }
526 }551 }
527 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))552 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
528- wv.signalIDs = append(wv.signalIDs, sigID)553+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
529 }554 }
530 555
531 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.556 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
@@ -555,7 +580,7 @@ func (wv *WebView) connectProgressSignal() {
555 }580 }
556 }581 }
557 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))582 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
558- wv.signalIDs = append(wv.signalIDs, sigID)583+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
559 }584 }
560 585
561 func (wv *WebView) connectDecidePolicySignal() {586 func (wv *WebView) connectDecidePolicySignal() {
@@ -571,7 +596,7 @@ func (wv *WebView) connectDecidePolicySignal() {
571 }596 }
572 }597 }
573 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)598 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
574- wv.signalIDs = append(wv.signalIDs, sigID)599+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
575 }600 }
576 601
577 // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).602 // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
@@ -770,7 +795,7 @@ func (wv *WebView) connectEnterFullscreenSignal() {
770 return false // Allow fullscreen795 return false // Allow fullscreen
771 }796 }
772 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)797 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
773- wv.signalIDs = append(wv.signalIDs, sigID)798+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
774 }799 }
775 800
776 func (wv *WebView) connectLeaveFullscreenSignal() {801 func (wv *WebView) connectLeaveFullscreenSignal() {
@@ -783,7 +808,7 @@ func (wv *WebView) connectLeaveFullscreenSignal() {
783 return false // Allow leaving fullscreen808 return false // Allow leaving fullscreen
784 }809 }
785 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)810 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
786- wv.signalIDs = append(wv.signalIDs, sigID)811+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
787 }812 }
788 813
789 func (wv *WebView) connectAudioStateSignal() {814 func (wv *WebView) connectAudioStateSignal() {
@@ -801,7 +826,7 @@ func (wv *WebView) connectAudioStateSignal() {
801 }826 }
802 }827 }
803 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))828 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
804- wv.signalIDs = append(wv.signalIDs, sigID)829+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
805 }830 }
806 831
807 func (wv *WebView) connectMouseTargetChangedSignal() {832 func (wv *WebView) connectMouseTargetChangedSignal() {
@@ -829,7 +854,7 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
829 wv.OnLinkHover(uri)854 wv.OnLinkHover(uri)
830 }855 }
831 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)856 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
832- wv.signalIDs = append(wv.signalIDs, sigID)857+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
833 }858 }
834 859
835 func (wv *WebView) connectBackForwardListChangedSignal() {860 func (wv *WebView) connectBackForwardListChangedSignal() {
@@ -857,7 +882,52 @@ func (wv *WebView) connectBackForwardListChangedSignal() {
857 }882 }
858 sigID := backForwardList.ConnectChanged(&changedCb)883 sigID := backForwardList.ConnectChanged(&changedCb)
859 wv.backForwardList = backForwardList884 wv.backForwardList = backForwardList
860- wv.backForwardListSignalID = sigID885+ wv.backForwardListSignalID = uintptr(sigID)
886+}
887+
888+func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
889+ switch reason {
890+ case webkit.WebProcessCrashedValue:
891+ return "crashed"
892+ case webkit.WebProcessExceededMemoryLimitValue:
893+ return "exceeded_memory"
894+ case webkit.WebProcessTerminatedByApiValue:
895+ return "terminated_by_api"
896+ default:
897+ return "unknown"
898+ }
899+}
900+
901+func mapWebProcessTerminationReason(reason webkit.WebProcessTerminationReason) port.WebProcessTerminationReason {
902+ switch reason {
903+ case webkit.WebProcessCrashedValue:
904+ return port.WebProcessTerminationCrashed
905+ case webkit.WebProcessExceededMemoryLimitValue:
906+ return port.WebProcessTerminationExceededMemory
907+ case webkit.WebProcessTerminatedByApiValue:
908+ return port.WebProcessTerminationByAPI
909+ default:
910+ return port.WebProcessTerminationUnknown
911+ }
912+}
913+
914+func (wv *WebView) connectWebProcessTerminatedSignal() {
915+ terminatedCb := func(_ webkit.WebView, reason webkit.WebProcessTerminationReason) {
916+ uri := wv.URI()
917+ reasonLabel := webProcessTerminationReasonString(reason)
918+ wv.logger.Warn().
919+ Uint64("id", uint64(wv.id)).
920+ Str("reason", reasonLabel).
921+ Int("reason_code", int(reason)).
922+ Str("uri", uri).
923+ Msg("web process terminated")
924+
925+ if wv.OnWebProcessTerminated != nil {
926+ wv.OnWebProcessTerminated(reason, reasonLabel, uri)
927+ }
928+ }
929+ sigID := wv.inner.ConnectWebProcessTerminated(&terminatedCb)
930+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
861 }931 }
862 932
863 // ID returns the unique identifier for this WebView.933 // ID returns the unique identifier for this WebView.
@@ -907,6 +977,7 @@ func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
907 if wv.destroyed.Load() {977 if wv.destroyed.Load() {
908 return fmt.Errorf("webview %d is destroyed", wv.id)978 return fmt.Errorf("webview %d is destroyed", wv.id)
909 }979 }
980+ wv.navigationActive.Store(true)
910 wv.inner.LoadUri(uri)981 wv.inner.LoadUri(uri)
911 logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")982 logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")
912 return nil983 return nil
@@ -917,6 +988,7 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error
917 if wv.destroyed.Load() {988 if wv.destroyed.Load() {
918 return fmt.Errorf("webview %d is destroyed", wv.id)989 return fmt.Errorf("webview %d is destroyed", wv.id)
919 }990 }
991+ wv.navigationActive.Store(true)
920 var baseURIPtr *string992 var baseURIPtr *string
921 if baseURI != "" {993 if baseURI != "" {
922 baseURIPtr = &baseURI994 baseURIPtr = &baseURI
@@ -1140,6 +1212,8 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1140 wv.OnFaviconChanged = nil1212 wv.OnFaviconChanged = nil
1141 wv.OnClose = nil1213 wv.OnClose = nil
1142 wv.OnCreate = nil1214 wv.OnCreate = nil
1215+ wv.OnLinkHover = nil
1216+ wv.OnWebProcessTerminated = nil
1143 return1217 return
1144 }1218 }
1145 1219
@@ -1179,6 +1253,13 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1179 }1253 }
1180 }1254 }
1181 wv.OnLinkHover = callbacks.OnLinkHover1255 wv.OnLinkHover = callbacks.OnLinkHover
1256+ if callbacks.OnWebProcessTerminated != nil {
1257+ wv.OnWebProcessTerminated = func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) {
1258+ callbacks.OnWebProcessTerminated(mapWebProcessTerminationReason(reason), reasonLabel, uri)
1259+ }
1260+ } else {
1261+ wv.OnWebProcessTerminated = nil
1262+ }
1182 }1263 }
1183 1264
1184 // ShowDevTools opens the WebKit inspector/developer tools.1265 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1215,6 +1296,16 @@ func (wv *WebView) IsDestroyed() bool {
1215 return wv.destroyed.Load()1296 return wv.destroyed.Load()
1216 }1297 }
1217 1298
1299+// IsRelated returns true when this WebView shares process/session with a parent popup.
1300+func (wv *WebView) IsRelated() bool {
1301+ return wv.isRelated
1302+}
1303+
1304+// HasNavigationActivity reports whether this WebView has been used for content navigation.
1305+func (wv *WebView) HasNavigationActivity() bool {
1306+ return wv.navigationActive.Load()
1307+}
1308+
1218 // Close triggers the close callback as if window.close() was called.1309 // Close triggers the close callback as if window.close() was called.
1219 // This is used for programmatic popup closure (e.g., OAuth auto-close).1310 // This is used for programmatic popup closure (e.g., OAuth auto-close).
1220 func (wv *WebView) Close() {1311 func (wv *WebView) Close() {
@@ -1236,13 +1327,13 @@ func (wv *WebView) DisconnectSignals() {
1236 1327
1237 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())1328 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
1238 for _, sigID := range wv.signalIDs {1329 for _, sigID := range wv.signalIDs {
1239- gobject.SignalHandlerDisconnect(obj, sigID)1330+ gobject.SignalHandlerDisconnect(obj, uint(sigID))
1240 }1331 }
1241 wv.signalIDs = wv.signalIDs[:0] // Clear the slice1332 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1242 1333
1243 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {1334 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1244 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())1335 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1245- gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)1336+ gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
1246 wv.backForwardListSignalID = 01337 wv.backForwardListSignalID = 0
1247 wv.backForwardList = nil1338 wv.backForwardList = nil
1248 }1339 }
@@ -1254,9 +1345,16 @@ func (wv *WebView) DisconnectSignals() {
1254 // This must be called when a WebView is no longer needed to free GPU resources,1345 // This must be called when a WebView is no longer needed to free GPU resources,
1255 // VA-API decoder contexts, and DMA-BUF buffers held by the web process.1346 // VA-API decoder contexts, and DMA-BUF buffers held by the web process.
1256 func (wv *WebView) Destroy() {1347 func (wv *WebView) Destroy() {
1348+ wv.DestroyWithPolicy("")
1349+}
1350+
1351+// DestroyWithPolicy cleans up the WebView resources with explicit process policy.
1352+// Valid policies: auto, always, never.
1353+func (wv *WebView) DestroyWithPolicy(policy string) {
1257 if wv.destroyed.Swap(true) {1354 if wv.destroyed.Swap(true) {
1258 return // Already destroyed1355 return // Already destroyed
1259 }1356 }
1357+ policy = resolveTerminatePolicy(policy)
1260 1358
1261 // 1. Disconnect all signal handlers first to prevent callbacks during cleanup1359 // 1. Disconnect all signal handlers first to prevent callbacks during cleanup
1262 wv.DisconnectSignals()1360 wv.DisconnectSignals()
@@ -1275,6 +1373,7 @@ func (wv *WebView) Destroy() {
1275 wv.OnLeaveFullscreen = nil1373 wv.OnLeaveFullscreen = nil
1276 wv.OnAudioStateChanged = nil1374 wv.OnAudioStateChanged = nil
1277 wv.OnLinkHover = nil1375 wv.OnLinkHover = nil
1376+ wv.OnWebProcessTerminated = nil
1278 1377
1279 // 3. Clear async callback references1378 // 3. Clear async callback references
1280 wv.mu.Lock()1379 wv.mu.Lock()
@@ -1287,11 +1386,9 @@ func (wv *WebView) Destroy() {
1287 wv.inner.Unparent()1386 wv.inner.Unparent()
1288 }1387 }
1289 1388
1290- // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)1389+ // 5. Optionally terminate web process (skip for related popup views sharing parent process).
1291- // This is critical to prevent zombie processes that hold video decoder resources.1390+ terminate := shouldTerminateWebProcess(policy, wv.isRelated, wv.navigationActive.Load())
1292- // IMPORTANT: Skip for related views (popups) - they share the web process with their1391+ if terminate && wv.inner != nil {
1293- // parent. Terminating the shared process would kill the parent WebView too!
1294- if !wv.isRelated && wv.inner != nil {
1295 wv.inner.TerminateWebProcess()1392 wv.inner.TerminateWebProcess()
1296 }1393 }
1297 1394
@@ -1303,13 +1400,176 @@ func (wv *WebView) Destroy() {
1303 wv.ucm = nil1400 wv.ucm = nil
1304 wv.findController = nil1401 wv.findController = nil
1305 1402
1306- if wv.isRelated {1403+ wv.logger.Debug().
1307- wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("related webview destroyed (process shared with parent)")1404+ Uint64("id", uint64(wv.id)).
1308- } else {1405+ Bool("related", wv.isRelated).
1309- wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")1406+ Bool("navigation_active", wv.navigationActive.Load()).
1407+ Bool("terminated_process", terminate).
1408+ Str("terminate_policy", policy).
1409+ Msg("webview destroyed")
1410+}
1411+
1412+// ResetForPoolReuse sanitizes callbacks/state so the WebView can be safely reused.
1413+func (wv *WebView) ResetForPoolReuse() {
1414+ if wv == nil || wv.destroyed.Load() {
1415+ return
1416+ }
1417+
1418+ wv.OnLoadChanged = nil
1419+ wv.OnTitleChanged = nil
1420+ wv.OnURIChanged = nil
1421+ wv.OnProgressChanged = nil
1422+ wv.OnFaviconChanged = nil
1423+ wv.OnClose = nil
1424+ wv.OnCreate = nil
1425+ wv.OnReadyToShow = nil
1426+ wv.OnLinkMiddleClick = nil
1427+ wv.OnEnterFullscreen = nil
1428+ wv.OnLeaveFullscreen = nil
1429+ wv.OnAudioStateChanged = nil
1430+ wv.OnLinkHover = nil
1431+ wv.OnWebProcessTerminated = nil
1432+
1433+ wv.mu.Lock()
1434+ wv.uri = ""
1435+ wv.title = ""
1436+ wv.progress = 0
1437+ wv.canGoBack = false
1438+ wv.canGoFwd = false
1439+ wv.isLoading = false
1440+ wv.asyncCallbacks = nil
1441+ wv.runJSErrorStats = make(map[string]runJSErrorStat)
1442+ wv.lastProgressUpdate.Store(0)
1443+ wv.mu.Unlock()
1444+
1445+ wv.isFullscreen.Store(false)
1446+ wv.isPlayingAudio.Store(false)
1447+ wv.navigationActive.Store(false)
1448+
1449+ if wv.inner != nil {
1450+ wv.inner.StopLoading()
1451+ wv.inner.SetVisible(false)
1452+ }
1453+}
1454+
1455+func resolveTerminatePolicy(raw string) string {
1456+ value := strings.ToLower(strings.TrimSpace(raw))
1457+ if value == "" {
1458+ value = strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_WEBVIEW_TERMINATE_POLICY")))
1459+ }
1460+ switch value {
1461+ case terminatePolicyAlways:
1462+ return terminatePolicyAlways
1463+ case terminatePolicyNever:
1464+ return terminatePolicyNever
1465+ case terminatePolicyAuto, "":
1466+ return terminatePolicyAuto
1467+ default:
1468+ return terminatePolicyAuto
1310 }1469 }
1311 }1470 }
1312 1471
1472+func shouldTerminateWebProcess(policy string, isRelated, navigationActive bool) bool {
1473+ if isRelated {
1474+ return false
1475+ }
1476+ switch resolveTerminatePolicy(policy) {
1477+ case terminatePolicyNever:
1478+ return false
1479+ case terminatePolicyAlways:
1480+ return true
1481+ case terminatePolicyAuto:
1482+ return navigationActive
1483+ default:
1484+ return navigationActive
1485+ }
1486+}
1487+
1488+func classifyRunJSEvaluateError(err error) (nonFatal bool, signature string) {
1489+ // Defensive guard: callers should only pass non-nil errors, but tolerate nil safely.
1490+ if err == nil {
1491+ return true, ""
1492+ }
1493+ msg := strings.ToLower(strings.TrimSpace(err.Error()))
1494+ signature = "evaluate_error:" + normalizeRunJSErrorSignature(msg)
1495+ switch {
1496+ case strings.Contains(msg, "canceled"),
1497+ strings.Contains(msg, "cancel"),
1498+ strings.Contains(msg, "javascript execution context"),
1499+ strings.Contains(msg, "execution context was destroyed"),
1500+ strings.Contains(msg, "document unloaded"):
1501+ return true, signature
1502+ default:
1503+ return false, signature
1504+ }
1505+}
1506+
1507+func normalizeRunJSErrorSignature(msg string) string {
1508+ msg = strings.ToLower(strings.TrimSpace(msg))
1509+ if msg == "" {
1510+ return "empty"
1511+ }
1512+ fields := strings.Fields(msg)
1513+ if len(fields) == 0 {
1514+ return "empty"
1515+ }
1516+ return strings.Join(fields, " ")
1517+}
1518+
1519+func (wv *WebView) runJSDomain() string {
1520+ wv.mu.RLock()
1521+ currentURI := strings.TrimSpace(wv.uri)
1522+ wv.mu.RUnlock()
1523+ if currentURI == "" {
1524+ return runJSUnknown
1525+ }
1526+ parsed, err := url.Parse(currentURI)
1527+ if err != nil {
1528+ return runJSUnknown
1529+ }
1530+ host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
1531+ if host == "" {
1532+ return runJSUnknown
1533+ }
1534+ return host
1535+}
1536+
1537+func (wv *WebView) shouldLogRunJSError(domain, signature string, nonFatal bool, now time.Time) (bool, uint64) {
1538+ if wv == nil {
1539+ return true, 1
1540+ }
1541+ if now.IsZero() {
1542+ now = time.Now()
1543+ }
1544+ if domain == "" {
1545+ domain = runJSUnknown
1546+ }
1547+ if signature == "" {
1548+ signature = runJSUnknown
1549+ }
1550+ key := domain + "|" + signature
1551+
1552+ wv.mu.Lock()
1553+ if wv.runJSErrorStats == nil {
1554+ wv.runJSErrorStats = make(map[string]runJSErrorStat)
1555+ }
1556+ stat := wv.runJSErrorStats[key]
1557+ stat.count++
1558+ count := stat.count
1559+
1560+ shouldLog := true
1561+ if nonFatal {
1562+ shouldLog = count == 1 || count%runJSAggregateLogEvery == 0 || now.Sub(stat.lastLog) >= runJSNonFatalLogInterval
1563+ }
1564+ if shouldLog {
1565+ stat.lastLog = now
1566+ }
1567+ wv.runJSErrorStats[key] = stat
1568+ wv.mu.Unlock()
1569+
1570+ return shouldLog, count
1571+}
1572+
1313 // RunJavaScript executes script in the specified world (empty for main world).1573 // RunJavaScript executes script in the specified world (empty for main world).
1314 // This is fire-and-forget: it does not block and errors are logged asynchronously.1574 // This is fire-and-forget: it does not block and errors are logged asynchronously.
1315 // Safe to call from any context including GTK signal handlers.1575 // Safe to call from any context including GTK signal handlers.
@@ -1324,25 +1584,68 @@ func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string)
1324 log := logging.FromContext(ctx)1584 log := logging.FromContext(ctx)
1325 1585
1326 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {1586 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
1587+ if wv.destroyed.Load() {
1588+ return
1589+ }
1590+ wv.mu.RLock()
1591+ inner := wv.inner
1592+ wv.mu.RUnlock()
1593+ if inner == nil {
1594+ return
1595+ }
1596+
1597+ domain := wv.runJSDomain()
1327 if resPtr == 0 {1598 if resPtr == 0 {
1328- log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result")1599+ signature := "nil_async_result"
1600+ shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now())
1601+ if shouldLog {
1602+ log.Debug().
1603+ Uint64("webview_id", uint64(wv.id)).
1604+ Str("domain", domain).
1605+ Str("signature", signature).
1606+ Uint64("repeat_count", count).
1607+ Msg("RunJavaScript: non-fatal nil async result")
1608+ }
1329 return1609 return
1330 }1610 }
1331 1611
1332 res := &gio.AsyncResultBase{Ptr: resPtr}1612 res := &gio.AsyncResultBase{Ptr: resPtr}
1333- value, err := wv.inner.EvaluateJavascriptFinish(res)1613+ value, err := inner.EvaluateJavascriptFinish(res)
1334 if err != nil {1614 if err != nil {
1335- log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed")1615+ nonFatal, signature := classifyRunJSEvaluateError(err)
1616+ shouldLog, count := wv.shouldLogRunJSError(domain, signature, nonFatal, time.Now())
1617+ if shouldLog {
1618+ ev := log.Warn()
1619+ msg := "RunJavaScript: failed"
1620+ if nonFatal {
1621+ ev = log.Debug()
1622+ msg = "RunJavaScript: non-fatal failure"
1623+ }
1624+ ev.Err(err).
1625+ Uint64("webview_id", uint64(wv.id)).
1626+ Str("domain", domain).
1627+ Str("signature", signature).
1628+ Uint64("repeat_count", count).
1629+ Msg(msg)
1630+ }
1336 return1631 return
1337 }1632 }
1338 1633
1339 if value != nil {1634 if value != nil {
1340 if jscCtx := value.GetContext(); jscCtx != nil {1635 if jscCtx := value.GetContext(); jscCtx != nil {
1341 if exc := jscCtx.GetException(); exc != nil {1636 if exc := jscCtx.GetException(); exc != nil {
1342- log.Warn().1637+ exceptionMessage := strings.TrimSpace(exc.GetMessage())
1343- Str("exception", exc.GetMessage()).1638+ signature := "js_exception:" + normalizeRunJSErrorSignature(exceptionMessage)
1344- Uint64("webview_id", uint64(wv.id)).1639+ shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now())
1345- Msg("RunJavaScript: JS exception")1640+ if shouldLog {
1641+ log.Debug().
1642+ Str("exception", exceptionMessage).
1643+ Uint64("webview_id", uint64(wv.id)).
1644+ Str("domain", domain).
1645+ Str("signature", signature).
1646+ Uint64("repeat_count", count).
1647+ Msg("RunJavaScript: JS exception")
1648+ }
1346 }1649 }
1347 }1650 }
1348 }1651 }
@@ -1359,7 +1662,13 @@ func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string)
1359 if worldName != "" {1662 if worldName != "" {
1360 worldNamePtr = &worldName1663 worldNamePtr = &worldName
1361 }1664 }
1362- wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)1665+ wv.mu.RLock()
1666+ inner := wv.inner
1667+ wv.mu.RUnlock()
1668+ if inner == nil || wv.destroyed.Load() {
1669+ return
1670+ }
1671+ inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
1363 }1672 }
1364 1673
1365 // AttachFrontend injects scripts/styles and wires the message router once per WebView.1674 // AttachFrontend injects scripts/styles and wires the message router once per WebView.
A internal/infrastructure/webkit/webview_runjavascript_test.go +89 -0
@@ -0,0 +1,89 @@
1+package webkit
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestClassifyRunJSEvaluateError(t *testing.T) {
12+ t.Run("non-fatal canceled", func(t *testing.T) {
13+ nonFatal, signature := classifyRunJSEvaluateError(errors.New("Operation was canceled"))
14+ assert.True(t, nonFatal)
15+ assert.Equal(t, "evaluate_error:operation was canceled", signature)
16+ })
17+
18+ t.Run("non-fatal context destroyed", func(t *testing.T) {
19+ nonFatal, signature := classifyRunJSEvaluateError(errors.New("JavaScript execution context was destroyed"))
20+ assert.True(t, nonFatal)
21+ assert.Equal(t, "evaluate_error:javascript execution context was destroyed", signature)
22+ })
23+
24+ t.Run("fatal unknown", func(t *testing.T) {
25+ nonFatal, signature := classifyRunJSEvaluateError(errors.New("segmentation fault in JS runtime"))
26+ assert.False(t, nonFatal)
27+ assert.Equal(t, "evaluate_error:segmentation fault in js runtime", signature)
28+ })
29+}
30+
31+func TestNormalizeRunJSErrorSignature(t *testing.T) {
32+ assert.Equal(t, "empty", normalizeRunJSErrorSignature(" \n\t "))
33+ assert.Equal(t, "foo bar baz", normalizeRunJSErrorSignature(" Foo BAR\tbaz "))
34+}
35+
36+func TestWebViewRunJSDomain(t *testing.T) {
37+ wv := &WebView{uri: "https://Sub.Example.com/path?q=1"}
38+ assert.Equal(t, "sub.example.com", wv.runJSDomain())
39+
40+ wv = &WebView{uri: "not a uri"}
41+ assert.Equal(t, "unknown", wv.runJSDomain())
42+}
43+
44+func TestShouldLogRunJSError_NonFatalAggregation(t *testing.T) {
45+ wv := &WebView{}
46+ now := time.Now()
47+
48+ shouldLog, count := wv.shouldLogRunJSError("example.com", "sig", true, now)
49+ assert.True(t, shouldLog)
50+ assert.Equal(t, uint64(1), count)
51+
52+ shouldLog, count = wv.shouldLogRunJSError("example.com", "sig", true, now.Add(10*time.Second))
53+ assert.False(t, shouldLog)
54+ assert.Equal(t, uint64(2), count)
55+
56+ shouldLog, count = wv.shouldLogRunJSError("example.com", "sig", true, now.Add(31*time.Second))
57+ assert.True(t, shouldLog)
58+ assert.Equal(t, uint64(3), count)
59+}
60+
61+func TestShouldLogRunJSError_NonFatalLogsEveryN(t *testing.T) {
62+ wv := &WebView{}
63+ base := time.Now()
64+ for i := 1; i < runJSAggregateLogEvery; i++ {
65+ shouldLog, _ := wv.shouldLogRunJSError("example.com", "sig", true, base)
66+ if i == 1 {
67+ assert.True(t, shouldLog)
68+ continue
69+ }
70+ assert.False(t, shouldLog)
71+ }
72+
73+ shouldLog, count := wv.shouldLogRunJSError("example.com", "sig", true, base)
74+ assert.True(t, shouldLog)
75+ assert.Equal(t, uint64(runJSAggregateLogEvery), count)
76+}
77+
78+func TestShouldLogRunJSError_FatalAlwaysLogs(t *testing.T) {
79+ wv := &WebView{}
80+ base := time.Now()
81+
82+ shouldLog, count := wv.shouldLogRunJSError("example.com", "fatal-sig", false, base)
83+ assert.True(t, shouldLog)
84+ assert.Equal(t, uint64(1), count)
85+
86+ shouldLog, count = wv.shouldLogRunJSError("example.com", "fatal-sig", false, base)
87+ assert.True(t, shouldLog)
88+ assert.Equal(t, uint64(2), count)
89+}
A internal/infrastructure/webkit/webview_termination_test.go +34 -0
@@ -0,0 +1,34 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestWebProcessTerminationReasonString(t *testing.T) {
11+ assert.Equal(t, "crashed", webProcessTerminationReasonString(webkitlib.WebProcessCrashedValue))
12+ assert.Equal(t, "exceeded_memory", webProcessTerminationReasonString(webkitlib.WebProcessExceededMemoryLimitValue))
13+ assert.Equal(t, "terminated_by_api", webProcessTerminationReasonString(webkitlib.WebProcessTerminatedByApiValue))
14+ assert.Equal(t, "unknown", webProcessTerminationReasonString(webkitlib.WebProcessTerminationReason(99)))
15+}
16+
17+func TestResolveTerminatePolicy(t *testing.T) {
18+ t.Setenv("DUMBER_WEBVIEW_TERMINATE_POLICY", "")
19+ assert.Equal(t, terminatePolicyAuto, resolveTerminatePolicy(""))
20+ assert.Equal(t, terminatePolicyAlways, resolveTerminatePolicy("always"))
21+ assert.Equal(t, terminatePolicyNever, resolveTerminatePolicy("never"))
22+ assert.Equal(t, terminatePolicyAuto, resolveTerminatePolicy("invalid"))
23+
24+ t.Setenv("DUMBER_WEBVIEW_TERMINATE_POLICY", "always")
25+ assert.Equal(t, terminatePolicyAlways, resolveTerminatePolicy(""))
26+}
27+
28+func TestShouldTerminateWebProcess(t *testing.T) {
29+ assert.False(t, shouldTerminateWebProcess(terminatePolicyAlways, true, true)) // isRelated=true, navigationActive=true
30+ assert.False(t, shouldTerminateWebProcess(terminatePolicyNever, false, true)) // isRelated=false, navigationActive=true
31+ assert.True(t, shouldTerminateWebProcess(terminatePolicyAlways, false, false)) // isRelated=false, navigationActive=false
32+ assert.True(t, shouldTerminateWebProcess(terminatePolicyAuto, false, true)) // isRelated=false, navigationActive=true
33+ assert.False(t, shouldTerminateWebProcess(terminatePolicyAuto, false, false)) // isRelated=false, navigationActive=false
34+}
M internal/ui/adapter/favicon.go +64 -4
@@ -12,6 +12,7 @@ import (
12 "github.com/jwijenbergh/puregotk/v4/gdk"12 "github.com/jwijenbergh/puregotk/v4/gdk"
13 "github.com/jwijenbergh/puregotk/v4/gio"13 "github.com/jwijenbergh/puregotk/v4/gio"
14 "github.com/jwijenbergh/puregotk/v4/glib"14 "github.com/jwijenbergh/puregotk/v4/glib"
15+ "github.com/rs/zerolog"
15 )16 )
16 17
17 // FaviconAdapter bridges the domain FaviconService to GTK by providing18 // FaviconAdapter bridges the domain FaviconService to GTK by providing
@@ -21,8 +22,12 @@ type FaviconAdapter struct {
21 faviconDB *webkit.FaviconDatabase22 faviconDB *webkit.FaviconDatabase
22 textureCache map[string]*gdk.Texture23 textureCache map[string]*gdk.Texture
23 mu sync.RWMutex24 mu sync.RWMutex
25+ warnMu sync.Mutex
26+ warnCounts map[string]int
24 }27 }
25 28
29+type warningLogFunc func(log *zerolog.Logger, err error)
30+
26 // NewFaviconAdapter creates a new FaviconAdapter.31 // NewFaviconAdapter creates a new FaviconAdapter.
27 // The faviconDB can be nil if WebKit favicon database is not available.32 // The faviconDB can be nil if WebKit favicon database is not available.
28 func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {33 func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
@@ -30,6 +35,7 @@ func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDataba
30 service: service,35 service: service,
31 faviconDB: faviconDB,36 faviconDB: faviconDB,
32 textureCache: make(map[string]*gdk.Texture),37 textureCache: make(map[string]*gdk.Texture),
38+ warnCounts: make(map[string]int),
33 }39 }
34 }40 }
35 41
@@ -195,6 +201,9 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
195 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.201 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
196 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {202 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
197 log := logging.FromContext(ctx)203 log := logging.FromContext(ctx)
204+ cacheDirWarnKey := "cache-dir"
205+ savePNGWarnKey := "save-png:" + domain
206+ sizedPNGWarnKey := "sized-png:" + domain
198 207
199 // Check if PNG save is needed before scheduling idle callback208 // Check if PNG save is needed before scheduling idle callback
200 pngPath := a.service.DiskPathPNG(domain)209 pngPath := a.service.DiskPathPNG(domain)
@@ -203,8 +212,12 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
203 if needsPNGSave {212 if needsPNGSave {
204 // Ensure cache directory exists (cheap check, do it now)213 // Ensure cache directory exists (cheap check, do it now)
205 if err := a.service.EnsureCacheDir(); err != nil {214 if err := a.service.EnsureCacheDir(); err != nil {
206- log.Warn().Err(err).Str("domain", domain).Msg("failed to create favicon cache dir")215+ a.logWarningDedup(ctx, cacheDirWarnKey, err, func(log *zerolog.Logger, warnErr error) {
216+ log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create favicon cache dir")
217+ })
207 needsPNGSave = false218 needsPNGSave = false
219+ } else {
220+ a.clearWarningDedup(cacheDirWarnKey)
208 }221 }
209 }222 }
210 223
@@ -213,15 +226,21 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
213 // texture.SaveToPng must run on main thread but we defer it to avoid blocking226 // texture.SaveToPng must run on main thread but we defer it to avoid blocking
214 cb := glib.SourceFunc(func(_ uintptr) bool {227 cb := glib.SourceFunc(func(_ uintptr) bool {
215 if ok := texture.SaveToPng(pngPath); !ok {228 if ok := texture.SaveToPng(pngPath); !ok {
216- log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")229+ a.logWarningDedup(ctx, savePNGWarnKey, nil, func(log *zerolog.Logger, _ error) {
230+ log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
231+ })
217 } else {232 } else {
233+ a.clearWarningDedup(savePNGWarnKey)
218 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")234 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
219 235
220 // Create normalized sized copy for dmenu/fuzzel (async)236 // Create normalized sized copy for dmenu/fuzzel (async)
221 go func() {237 go func() {
222 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {238 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
223- log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")239+ a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
240+ log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
241+ })
224 } else {242 } else {
243+ a.clearWarningDedup(sizedPNGWarnKey)
225 log.Debug().Str("domain", domain).Msg("created sized PNG")244 log.Debug().Str("domain", domain).Msg("created sized PNG")
226 }245 }
227 }()246 }()
@@ -318,13 +337,54 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
318 if hasPNG && !hasSized {337 if hasPNG && !hasSized {
319 log.Debug().Str("domain", domain).Msg("creating sized PNG")338 log.Debug().Str("domain", domain).Msg("creating sized PNG")
320 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {339 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
321- log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")340+ a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
341+ log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
342+ })
322 } else {343 } else {
344+ a.clearWarningDedup("sized-png:" + domain)
323 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")345 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
324 }346 }
325 }347 }
326 }348 }
327 349
350+func (a *FaviconAdapter) shouldLogWarningDedup(key string) (bool, int) {
351+ a.warnMu.Lock()
352+ defer a.warnMu.Unlock()
353+
354+ count := a.warnCounts[key] + 1
355+ a.warnCounts[key] = count
356+
357+ return count == 1, count - 1
358+}
359+
360+func (a *FaviconAdapter) clearWarningDedup(key string) {
361+ a.warnMu.Lock()
362+ delete(a.warnCounts, key)
363+ a.warnMu.Unlock()
364+}
365+
366+func (a *FaviconAdapter) logWarningDedup(
367+ ctx context.Context,
368+ key string,
369+ err error,
370+ warnFn warningLogFunc,
371+) {
372+ log := logging.FromContext(ctx)
373+ logWarning, suppressed := a.shouldLogWarningDedup(key)
374+ if logWarning {
375+ warnFn(log, err)
376+ return
377+ }
378+
379+ if suppressed == 1 {
380+ event := log.Debug()
381+ if err != nil {
382+ event = event.Err(err)
383+ }
384+ event.Str("dedupe_key", key).Msg("suppressing repeated favicon warning")
385+ }
386+}
387+
328 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.388 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
329 // The texture is cached under the InternalDomain key.389 // The texture is cached under the InternalDomain key.
330 func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {390 func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
A internal/ui/adapter/favicon_test.go +73 -0
@@ -0,0 +1,73 @@
1+package adapter
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+
7+ "github.com/rs/zerolog"
8+)
9+
10+func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
11+ adapter := NewFaviconAdapter(nil, nil)
12+
13+ first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com")
14+ if !first {
15+ t.Fatalf("expected first warning to be logged")
16+ }
17+ if suppressed != 0 {
18+ t.Fatalf("expected no suppressed count on first warning, got %d", suppressed)
19+ }
20+
21+ first, suppressed = adapter.shouldLogWarningDedup("save-png:example.com")
22+ if first {
23+ t.Fatalf("expected repeated warning to be suppressed")
24+ }
25+ if suppressed != 1 {
26+ t.Fatalf("expected suppressed count to be 1, got %d", suppressed)
27+ }
28+
29+ first, suppressed = adapter.shouldLogWarningDedup("save-png:example.com")
30+ if first {
31+ t.Fatalf("expected repeated warning to remain suppressed")
32+ }
33+ if suppressed != 2 {
34+ t.Fatalf("expected suppressed count to be 2, got %d", suppressed)
35+ }
36+}
37+
38+func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
39+ adapter := NewFaviconAdapter(nil, nil)
40+ key := "sized-png:example.com"
41+
42+ first, _ := adapter.shouldLogWarningDedup(key)
43+ if !first {
44+ t.Fatalf("expected first warning to be logged")
45+ }
46+
47+ adapter.clearWarningDedup(key)
48+
49+ first, suppressed := adapter.shouldLogWarningDedup(key)
50+ if !first {
51+ t.Fatalf("expected warning to log again after clear")
52+ }
53+ if suppressed != 0 {
54+ t.Fatalf("expected suppressed count reset after clear, got %d", suppressed)
55+ }
56+}
57+
58+func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
59+ adapter := NewFaviconAdapter(nil, nil)
60+ key := "save-png:example.com"
61+ var calls atomic.Int32
62+
63+ adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
64+ calls.Add(1)
65+ })
66+ adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
67+ calls.Add(1)
68+ })
69+
70+ if got := calls.Load(); got != 1 {
71+ t.Fatalf("expected warning callback to run once, got %d", got)
72+ }
73+}
M internal/ui/app.go +3 -0
@@ -1393,6 +1393,9 @@ func (a *App) initCoordinators(ctx context.Context) {
1393 a.deps.NavigateUC,1393 a.deps.NavigateUC,
1394 a.contentCoord,1394 a.contentCoord,
1395 )1395 )
1396+ a.wsCoord.SetOnPaneClosed(func(paneID entity.PaneID) {
1397+ a.navCoord.ClearPaneHistory(paneID)
1398+ })
1396 1399
1397 // Wire title updates to history persistence1400 // Wire title updates to history persistence
1398 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {1401 a.contentCoord.SetOnTitleUpdated(func(ctx context.Context, paneID entity.PaneID, url, title string) {
M internal/ui/component/loading_skeleton.go +42 -12
@@ -1,25 +1,38 @@
1 package component1 package component
2 2
3 import (3 import (
4+ "sync/atomic"
5+
4 "github.com/bnema/dumber/internal/ui/layout"6 "github.com/bnema/dumber/internal/ui/layout"
5 "github.com/jwijenbergh/puregotk/v4/gtk"7 "github.com/jwijenbergh/puregotk/v4/gtk"
6 )8 )
7 9
8 // LoadingSkeleton displays a themed loading placeholder.10 // LoadingSkeleton displays a themed loading placeholder.
9 // It is intended to be embedded as an overlay while primary content is loading.11 // It is intended to be embedded as an overlay while primary content is loading.
10-// Shows a faded app logo and spinner.12+// Shows a faded app logo, a discrete spinner, and the version below.
11 type LoadingSkeleton struct {13 type LoadingSkeleton struct {
12 container layout.BoxWidget14 container layout.BoxWidget
13 content layout.BoxWidget15 content layout.BoxWidget
14 spinner layout.SpinnerWidget16 spinner layout.SpinnerWidget
15 logo layout.ImageWidget17 logo layout.ImageWidget
18+ version layout.LabelWidget
16 }19 }
17 20
18 const (21 const (
19- loadingSkeletonSpacing = 622+ loadingSkeletonSpacing = 6
20- loadingSkeletonLogoSize = 51223+ loadingSkeletonLogoSize = 256
24+ loadingSkeletonSpinnerSize = 32
25+ loadingSkeletonVersionMaxChars = 16
21 )26 )
22 27
28+// skeletonVersion is set via SetSkeletonVersion during startup.
29+var skeletonVersion atomic.Value
30+
31+// SetSkeletonVersion sets the version string shown on loading skeletons.
32+func SetSkeletonVersion(v string) {
33+ skeletonVersion.Store(v)
34+}
35+
23 func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {36 func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
24 container := factory.NewBox(layout.OrientationVertical, 0)37 container := factory.NewBox(layout.OrientationVertical, 0)
25 container.SetHexpand(true)38 container.SetHexpand(true)
@@ -37,14 +50,6 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
37 content.SetCanTarget(false)50 content.SetCanTarget(false)
38 content.AddCssClass("loading-skeleton-content")51 content.AddCssClass("loading-skeleton-content")
39 52
40- spinner := factory.NewSpinner()
41- spinner.SetHalign(gtk.AlignCenterValue)
42- spinner.SetValign(gtk.AlignCenterValue)
43- spinner.SetCanFocus(false)
44- spinner.SetCanTarget(false)
45- spinner.SetSizeRequest(32, 32)
46- spinner.AddCssClass("loading-skeleton-spinner")
47-
48 // Faded app logo53 // Faded app logo
49 logo := factory.NewImage()54 logo := factory.NewImage()
50 logo.SetHalign(gtk.AlignCenterValue)55 logo.SetHalign(gtk.AlignCenterValue)
@@ -60,9 +65,33 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
60 logo.SetFromPaintable(logoTexture)65 logo.SetFromPaintable(logoTexture)
61 }66 }
62 67
63- // Layout: logo, spinner (vertically centered)68+ // Discrete spinner between logo and version
69+ spinner := factory.NewSpinner()
70+ spinner.SetHalign(gtk.AlignCenterValue)
71+ spinner.SetValign(gtk.AlignCenterValue)
72+ spinner.SetCanFocus(false)
73+ spinner.SetCanTarget(false)
74+ spinner.SetSizeRequest(loadingSkeletonSpinnerSize, loadingSkeletonSpinnerSize)
75+ spinner.AddCssClass("loading-skeleton-spinner")
76+
77+ // Version label below spinner
78+ versionText, _ := skeletonVersion.Load().(string)
79+ if versionText == "" {
80+ versionText = "dev"
81+ }
82+ versionLabel := factory.NewLabel(versionText)
83+ versionLabel.SetHalign(gtk.AlignCenterValue)
84+ versionLabel.SetValign(gtk.AlignCenterValue)
85+ versionLabel.SetCanFocus(false)
86+ versionLabel.SetCanTarget(false)
87+ versionLabel.SetMaxWidthChars(loadingSkeletonVersionMaxChars)
88+ versionLabel.SetEllipsize(layout.EllipsizeEnd)
89+ versionLabel.AddCssClass("loading-skeleton-version")
90+
91+ // Layout: logo, spinner, version (vertically centered)
64 content.Append(logo)92 content.Append(logo)
65 content.Append(spinner)93 content.Append(spinner)
94+ content.Append(versionLabel)
66 container.Append(content)95 container.Append(content)
67 96
68 ls := &LoadingSkeleton{97 ls := &LoadingSkeleton{
@@ -70,6 +99,7 @@ func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
70 content: content,99 content: content,
71 spinner: spinner,100 spinner: spinner,
72 logo: logo,101 logo: logo,
102+ version: versionLabel,
73 }103 }
74 ls.SetVisible(true)104 ls.SetVisible(true)
75 return ls105 return ls
M internal/ui/component/loading_skeleton_test.go +42 -20
@@ -17,6 +17,7 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
17 mockContent := mocks.NewMockBoxWidget(t)17 mockContent := mocks.NewMockBoxWidget(t)
18 mockSpinner := mocks.NewMockSpinnerWidget(t)18 mockSpinner := mocks.NewMockSpinnerWidget(t)
19 mockLogo := mocks.NewMockImageWidget(t)19 mockLogo := mocks.NewMockImageWidget(t)
20+ mockVersion := mocks.NewMockLabelWidget(t)
20 21
21 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()22 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
22 mockContainer.EXPECT().SetHexpand(true).Once()23 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -34,26 +35,36 @@ func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
34 mockContent.EXPECT().SetCanTarget(false).Once()35 mockContent.EXPECT().SetCanTarget(false).Once()
35 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()36 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
36 37
37- mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
38- mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
39- mockSpinner.EXPECT().SetValign(mock.Anything).Once()
40- mockSpinner.EXPECT().SetCanFocus(false).Once()
41- mockSpinner.EXPECT().SetCanTarget(false).Once()
42- mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
43- mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
44-
45 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()38 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
46 mockLogo.EXPECT().SetHalign(mock.Anything).Once()39 mockLogo.EXPECT().SetHalign(mock.Anything).Once()
47 mockLogo.EXPECT().SetValign(mock.Anything).Once()40 mockLogo.EXPECT().SetValign(mock.Anything).Once()
48 mockLogo.EXPECT().SetCanFocus(false).Once()41 mockLogo.EXPECT().SetCanFocus(false).Once()
49 mockLogo.EXPECT().SetCanTarget(false).Once()42 mockLogo.EXPECT().SetCanTarget(false).Once()
50- mockLogo.EXPECT().SetSizeRequest(512, 512).Once()43+ mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once()
51- mockLogo.EXPECT().SetPixelSize(512).Once()44+ mockLogo.EXPECT().SetPixelSize(mock.Anything).Once()
52 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()45 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
53 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()46 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
54 47
48+ mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
49+ mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
50+ mockSpinner.EXPECT().SetValign(mock.Anything).Once()
51+ mockSpinner.EXPECT().SetCanFocus(false).Once()
52+ mockSpinner.EXPECT().SetCanTarget(false).Once()
53+ mockSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once()
54+ mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
55+
56+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockVersion).Once()
57+ mockVersion.EXPECT().SetHalign(mock.Anything).Once()
58+ mockVersion.EXPECT().SetValign(mock.Anything).Once()
59+ mockVersion.EXPECT().SetCanFocus(false).Once()
60+ mockVersion.EXPECT().SetCanTarget(false).Once()
61+ mockVersion.EXPECT().SetMaxWidthChars(mock.Anything).Once()
62+ mockVersion.EXPECT().SetEllipsize(mock.Anything).Once()
63+ mockVersion.EXPECT().AddCssClass("loading-skeleton-version").Once()
64+
55 mockContent.EXPECT().Append(mockLogo).Once()65 mockContent.EXPECT().Append(mockLogo).Once()
56 mockContent.EXPECT().Append(mockSpinner).Once()66 mockContent.EXPECT().Append(mockSpinner).Once()
67+ mockContent.EXPECT().Append(mockVersion).Once()
57 mockContainer.EXPECT().Append(mockContent).Once()68 mockContainer.EXPECT().Append(mockContent).Once()
58 69
59 // NewLoadingSkeleton ends by calling SetVisible(true).70 // NewLoadingSkeleton ends by calling SetVisible(true).
@@ -70,6 +81,7 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
70 mockContent := mocks.NewMockBoxWidget(t)81 mockContent := mocks.NewMockBoxWidget(t)
71 mockSpinner := mocks.NewMockSpinnerWidget(t)82 mockSpinner := mocks.NewMockSpinnerWidget(t)
72 mockLogo := mocks.NewMockImageWidget(t)83 mockLogo := mocks.NewMockImageWidget(t)
84+ mockVersion := mocks.NewMockLabelWidget(t)
73 85
74 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()86 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
75 mockContainer.EXPECT().SetHexpand(true).Once()87 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -87,26 +99,36 @@ func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
87 mockContent.EXPECT().SetCanTarget(false).Once()99 mockContent.EXPECT().SetCanTarget(false).Once()
88 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()100 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
89 101
90- mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
91- mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
92- mockSpinner.EXPECT().SetValign(mock.Anything).Once()
93- mockSpinner.EXPECT().SetCanFocus(false).Once()
94- mockSpinner.EXPECT().SetCanTarget(false).Once()
95- mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
96- mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
97-
98 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()102 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
99 mockLogo.EXPECT().SetHalign(mock.Anything).Once()103 mockLogo.EXPECT().SetHalign(mock.Anything).Once()
100 mockLogo.EXPECT().SetValign(mock.Anything).Once()104 mockLogo.EXPECT().SetValign(mock.Anything).Once()
101 mockLogo.EXPECT().SetCanFocus(false).Once()105 mockLogo.EXPECT().SetCanFocus(false).Once()
102 mockLogo.EXPECT().SetCanTarget(false).Once()106 mockLogo.EXPECT().SetCanTarget(false).Once()
103- mockLogo.EXPECT().SetSizeRequest(512, 512).Once()107+ mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once()
104- mockLogo.EXPECT().SetPixelSize(512).Once()108+ mockLogo.EXPECT().SetPixelSize(mock.Anything).Once()
105 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()109 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
106 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()110 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
107 111
112+ mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
113+ mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
114+ mockSpinner.EXPECT().SetValign(mock.Anything).Once()
115+ mockSpinner.EXPECT().SetCanFocus(false).Once()
116+ mockSpinner.EXPECT().SetCanTarget(false).Once()
117+ mockSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once()
118+ mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
119+
120+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockVersion).Once()
121+ mockVersion.EXPECT().SetHalign(mock.Anything).Once()
122+ mockVersion.EXPECT().SetValign(mock.Anything).Once()
123+ mockVersion.EXPECT().SetCanFocus(false).Once()
124+ mockVersion.EXPECT().SetCanTarget(false).Once()
125+ mockVersion.EXPECT().SetMaxWidthChars(mock.Anything).Once()
126+ mockVersion.EXPECT().SetEllipsize(mock.Anything).Once()
127+ mockVersion.EXPECT().AddCssClass("loading-skeleton-version").Once()
128+
108 mockContent.EXPECT().Append(mockLogo).Once()129 mockContent.EXPECT().Append(mockLogo).Once()
109 mockContent.EXPECT().Append(mockSpinner).Once()130 mockContent.EXPECT().Append(mockSpinner).Once()
131+ mockContent.EXPECT().Append(mockVersion).Once()
110 mockContainer.EXPECT().Append(mockContent).Once()132 mockContainer.EXPECT().Append(mockContent).Once()
111 133
112 mockContainer.EXPECT().SetVisible(true).Once()134 mockContainer.EXPECT().SetVisible(true).Once()
M internal/ui/component/omnibox.go +220 -209
@@ -3,7 +3,7 @@ package component
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "math"6+ stdurl "net/url"
7 "strings"7 "strings"
8 "sync"8 "sync"
9 "time"9 "time"
@@ -21,15 +21,12 @@ import (
21 "github.com/jwijenbergh/puregotk/v4/glib"21 "github.com/jwijenbergh/puregotk/v4/glib"
22 "github.com/jwijenbergh/puregotk/v4/graphene"22 "github.com/jwijenbergh/puregotk/v4/graphene"
23 "github.com/jwijenbergh/puregotk/v4/gtk"23 "github.com/jwijenbergh/puregotk/v4/gtk"
24- "github.com/jwijenbergh/puregotk/v4/pango"
25 )24 )
26 25
27 const (26 const (
28 debounceDelayMs = 5027 debounceDelayMs = 50
29 endBoxSpacing = 628 endBoxSpacing = 6
30 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"29 defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31- ghostPositionRetryMs = 16
32- ghostPositionMaxAttempts = 5
33 minGhostInputLength = 130 minGhostInputLength = 1
34 )31 )
35 32
@@ -76,8 +73,6 @@ type Omnibox struct {
76 zoomLabel *gtk.Label73 zoomLabel *gtk.Label
77 bangBadge *gtk.Label74 bangBadge *gtk.Label
78 entry *gtk.SearchEntry75 entry *gtk.SearchEntry
79- entryOverlay *gtk.Overlay // Overlay for ghost text
80- ghostLabel *gtk.Label // Ghost text completion label
81 scrolledWin *gtk.ScrolledWindow76 scrolledWin *gtk.ScrolledWindow
82 listBox *gtk.ListBox77 listBox *gtk.ListBox
83 78
@@ -123,6 +118,8 @@ type Omnibox struct {
123 debounceTimer *time.Timer118 debounceTimer *time.Timer
124 debounceMu sync.Mutex119 debounceMu sync.Mutex
125 lastQuery string // Prevent duplicate searches120 lastQuery string // Prevent duplicate searches
121+ searchToken uint64
122+ ghostToken uint64
126 123
127 // Scaling124 // Scaling
128 uiScale float64125 uiScale float64
@@ -512,30 +509,6 @@ func (o *Omnibox) initEntry() error {
512 placeholder := defaultOmniboxPlaceholder509 placeholder := defaultOmniboxPlaceholder
513 o.entry.SetPlaceholderText(&placeholder)510 o.entry.SetPlaceholderText(&placeholder)
514 511
515- // Create overlay for ghost text
516- o.entryOverlay = gtk.NewOverlay()
517- if o.entryOverlay == nil {
518- return errNilWidget("entryOverlay")
519- }
520- o.entryOverlay.SetChild(&o.entry.Widget)
521- o.entryOverlay.SetHexpand(true)
522-
523- // Create ghost label for autocomplete suffix
524- o.ghostLabel = gtk.NewLabel(nil)
525- if o.ghostLabel == nil {
526- return errNilWidget("ghostLabel")
527- }
528- o.ghostLabel.AddCssClass("omnibox-ghost")
529- o.ghostLabel.SetHalign(gtk.AlignStartValue)
530- o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text
531- o.ghostLabel.SetXalign(0)
532- o.ghostLabel.SetVisible(false)
533- o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
534- o.ghostLabel.SetEllipsize(pango.EllipsizeEndValue) // Truncate with ellipsis if too long
535-
536- o.entryOverlay.AddOverlay(&o.ghostLabel.Widget)
537- o.entryOverlay.SetClipOverlay(&o.ghostLabel.Widget, true) // Clip ghost text to entry bounds
538-
539 return nil512 return nil
540 }513 }
541 514
@@ -636,7 +609,7 @@ func (o *Omnibox) assembleWidgets() {
636 609
637 o.scrolledWin.SetChild(&o.listBox.Widget)610 o.scrolledWin.SetChild(&o.listBox.Widget)
638 o.mainBox.Append(&o.headerBox.Widget)611 o.mainBox.Append(&o.headerBox.Widget)
639- o.mainBox.Append(&o.entryOverlay.Widget) // Use overlay for ghost text support612+ o.mainBox.Append(&o.entry.Widget)
640 o.mainBox.Append(&o.scrolledWin.Widget)613 o.mainBox.Append(&o.scrolledWin.Widget)
641 o.outerBox.Append(&o.mainBox.Widget)614 o.outerBox.Append(&o.mainBox.Widget)
642 }615 }
@@ -825,6 +798,10 @@ func (o *Omnibox) onEntryChanged() {
825 return798 return
826 }799 }
827 800
801+ if o.isGhostProgrammaticEcho(entryText) {
802+ return
803+ }
804+
828 // Reset navigation state when user types - space should type, not toggle favorite805 // Reset navigation state when user types - space should type, not toggle favorite
829 o.mu.Lock()806 o.mu.Lock()
830 o.hasNavigated = false807 o.hasNavigated = false
@@ -850,169 +827,129 @@ func (o *Omnibox) onEntryChanged() {
850 o.debounceMu.Unlock()827 o.debounceMu.Unlock()
851 }828 }
852 829
853-// setGhostText displays a ghost completion suffix in the overlay label.830+func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
854-// The ghost text shows what would complete the current input.831+ o.mu.RLock()
855-// originalInput is the input that was used for the autocomplete query.832+ hasGhost := o.hasGhostText
856-// If originalInput is empty, the ghost text replaces the entire input (used for row selection).833+ realInput := o.realInput
834+ ghostFullText := o.ghostFullText
835+ o.mu.RUnlock()
836+
837+ if !hasGhost || ghostFullText == "" || entryText != ghostFullText {
838+ return false
839+ }
840+
841+ selectionStart := 0
842+ selectionEnd := 0
843+ hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd)
844+ return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection)
845+}
846+
847+func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool {
848+ if !hasSelection {
849+ return false
850+ }
851+ prefixRunes := utf8.RuneCountInString(realInput)
852+ fullRunes := utf8.RuneCountInString(ghostFullText)
853+ if fullRunes <= prefixRunes {
854+ return false
855+ }
856+ return selectionStart == prefixRunes && selectionEnd == fullRunes
857+}
858+
859+// setGhostText applies inline completion using entry selection.
860+// It writes full text and selects [len(originalInput):end] as the ghost suffix.
857 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {861 func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
858- if o.ghostLabel == nil || suffix == "" {862+ if o.entry == nil || suffix == "" {
859 o.clearGhostText()863 o.clearGhostText()
860 return864 return
861 }865 }
862 866
863- // Store ghost state optimistically - will be validated on main thread867+ displayText := originalInput + suffix
868+ if fullText != "" {
869+ displayText = fullText
870+ }
871+ selectionStart := utf8.RuneCountInString(originalInput)
872+ selectionEnd := utf8.RuneCountInString(displayText)
873+
864 o.mu.Lock()874 o.mu.Lock()
875+ o.realInput = originalInput
865 o.ghostSuffix = suffix876 o.ghostSuffix = suffix
866 o.ghostFullText = fullText877 o.ghostFullText = fullText
867 o.hasGhostText = true878 o.hasGhostText = true
879+ capturedToken := o.ghostToken
868 o.mu.Unlock()880 o.mu.Unlock()
869 881
870- o.scheduleGhostPosition(originalInput, suffix, 0)
871-}
872-
873-func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
874 var cb glib.SourceFunc = func(uintptr) bool {882 var cb glib.SourceFunc = func(uintptr) bool {
875- if o.ghostLabel == nil || o.entry == nil {883+ if o.entry == nil {
876- return false
877- }
878- if o.shouldSkipGhostUpdate(originalInput) {
879 return false884 return false
880 }885 }
881- if !o.ghostPositionReady(attempt) {886+
882- o.retryGhostPosition(originalInput, suffix, attempt+1)887+ o.mu.RLock()
888+ currentInput := o.realInput
889+ currentToken := o.ghostToken
890+ o.mu.RUnlock()
891+ if currentInput != originalInput {
883 return false892 return false
884 }893 }
885-894+ if capturedToken != currentToken {
886- marginStart, ok := o.computeGhostMarginStart(originalInput)
887- if !ok {
888 return false895 return false
889 }896 }
890- o.applyGhostPosition(suffix, marginStart)897+ o.mu.Lock()
891- return false898+ o.isAcceptingGhost = true
892- }899+ o.mu.Unlock()
893- glib.IdleAdd(&cb, 0)
894-}
895-
896-func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
897- if originalInput == "" {
898- return false
899- }
900- return o.entry.GetText() != originalInput
901-}
902-
903-func (o *Omnibox) ghostPositionReady(attempt int) bool {
904- entryWidth := o.entry.GetAllocatedWidth()
905- overlayWidth := 0
906- if o.entryOverlay != nil {
907- overlayWidth = o.entryOverlay.GetAllocatedWidth()
908- }
909-
910- if entryWidth > 0 && overlayWidth > 0 {
911- return true
912- }
913-
914- // Max attempts reached but widgets still not allocated - give up
915- if attempt >= ghostPositionMaxAttempts {
916- log := logging.FromContext(o.ctx)
917- log.Debug().
918- Int("attempt", attempt).
919- Int("entryWidth", entryWidth).
920- Int("overlayWidth", overlayWidth).
921- Msg("ghost position timeout, clearing ghost text")
922- o.clearGhostText()
923- return false
924- }
925 900
926- return false901+ o.entry.SetText(displayText)
927-}902+ o.entry.SetPosition(selectionStart)
903+ o.entry.SelectRegion(selectionStart, selectionEnd)
928 904
929-func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {905+ o.mu.Lock()
930- var retryCb glib.SourceFunc = func(uintptr) bool {906+ o.isAcceptingGhost = false
931- o.scheduleGhostPosition(originalInput, suffix, attempt)907+ o.mu.Unlock()
932 return false908 return false
933 }909 }
934- glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)910+ glib.IdleAdd(&cb, 0)
935-}
936-
937-func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
938- pangoCtx := o.entry.GetPangoContext()
939- if pangoCtx == nil {
940- return 0, false
941- }
942-
943- textLayout := pango.NewLayout(pangoCtx)
944- defer textLayout.Unref() // Prevent memory leak
945- textLayout.SetText(originalInput, len(originalInput))
946-
947- var widthPx, heightPx int
948- textLayout.GetPixelSize(&widthPx, &heightPx)
949-
950- marginStart := widthPx
951- delegate := o.entry.GetDelegate()
952- if delegate != nil && o.entryOverlay != nil {
953- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
954- if textWidget != nil {
955- var textBounds graphene.Rect
956- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
957- textStart := float64(textBounds.GetX())
958- cursorPos := uint(utf8.RuneCountInString(originalInput))
959- var strongRect, weakRect graphene.Rect
960- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
961- cursorX := float64(textBounds.GetX() + strongRect.GetX())
962- marginStart = int(math.Round(cursorX))
963- if cursorX < textStart {
964- marginStart = int(math.Round(textStart))
965- }
966- }
967- }
968- }
969-
970- if marginStart < 0 {
971- marginStart = 0
972- }
973- if o.entryOverlay != nil {
974- var entryBounds graphene.Rect
975- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
976- minStart := int(math.Round(float64(entryBounds.GetX())))
977- if marginStart < minStart {
978- marginStart = minStart
979- }
980- }
981- }
982-
983- return marginStart, true
984-}
985-
986-func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
987- o.ghostLabel.SetText(suffix)
988- o.ghostLabel.SetMarginStart(marginStart)
989- o.ghostLabel.SetVisible(true)
990- emptyPlaceholder := ""
991- o.entry.SetPlaceholderText(&emptyPlaceholder)
992 }911 }
993 912
994 // clearGhostText hides the ghost completion text.913 // clearGhostText hides the ghost completion text.
995 func (o *Omnibox) clearGhostText() {914 func (o *Omnibox) clearGhostText() {
996 o.mu.Lock()915 o.mu.Lock()
916+ realInput := o.realInput
917+ ghostFullText := o.ghostFullText
918+ hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
919+ o.ghostToken++
920+ capturedToken := o.ghostToken
997 o.ghostSuffix = ""921 o.ghostSuffix = ""
998 o.ghostFullText = ""922 o.ghostFullText = ""
999 o.hasGhostText = false923 o.hasGhostText = false
1000 o.mu.Unlock()924 o.mu.Unlock()
1001 925
1002- if o.ghostLabel == nil {926+ if !hadGhost || o.entry == nil {
1003 return927 return
1004 }928 }
1005 929
1006 var cb glib.SourceFunc = func(uintptr) bool {930 var cb glib.SourceFunc = func(uintptr) bool {
1007- if o.ghostLabel != nil {931+ if o.entry == nil {
1008- o.ghostLabel.SetVisible(false)932+ return false
1009- o.ghostLabel.SetText("")
1010 }933 }
1011- // Restore placeholder text934+ o.mu.RLock()
1012- if o.entry != nil {935+ currentToken := o.ghostToken
1013- placeholder := defaultOmniboxPlaceholder936+ o.mu.RUnlock()
1014- o.entry.SetPlaceholderText(&placeholder)937+ if capturedToken != currentToken {
938+ return false
1015 }939 }
940+ o.mu.Lock()
941+ o.isAcceptingGhost = true
942+ o.mu.Unlock()
943+ // Only revert text when the entry still shows the previous ghost completion.
944+ // If the user typed since scheduling this idle callback, never overwrite it.
945+ if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput {
946+ o.entry.SetText(realInput)
947+ }
948+ o.entry.SelectRegion(-1, -1)
949+ o.entry.SetPosition(-1)
950+ o.mu.Lock()
951+ o.isAcceptingGhost = false
952+ o.mu.Unlock()
1016 return false953 return false
1017 }954 }
1018 glib.IdleAdd(&cb, 0)955 glib.IdleAdd(&cb, 0)
@@ -1021,54 +958,42 @@ func (o *Omnibox) clearGhostText() {
1021 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.958 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
1022 // This prevents stale background results from wiping newer ghost text.959 // This prevents stale background results from wiping newer ghost text.
1023 func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {960 func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1024- if o.entry == nil {961+ o.mu.RLock()
962+ currentInput := o.realInput
963+ o.mu.RUnlock()
964+ if currentInput != expectedInput {
1025 return965 return
1026 }966 }
1027-967+ o.clearGhostText()
1028- var cb glib.SourceFunc = func(uintptr) bool {
1029- if o.entry == nil {
1030- return false
1031- }
1032- if o.entry.GetText() != expectedInput {
1033- return false
1034- }
1035- o.mu.Lock()
1036- o.ghostSuffix = ""
1037- o.ghostFullText = ""
1038- o.hasGhostText = false
1039- o.mu.Unlock()
1040- if o.ghostLabel != nil {
1041- o.ghostLabel.SetVisible(false)
1042- o.ghostLabel.SetText("")
1043- }
1044- placeholder := defaultOmniboxPlaceholder
1045- o.entry.SetPlaceholderText(&placeholder)
1046- return false
1047- }
1048- glib.IdleAdd(&cb, 0)
1049 }968 }
1050 969
1051 // acceptGhostCompletion accepts the ghost text and fills the input.970 // acceptGhostCompletion accepts the ghost text and fills the input.
1052 func (o *Omnibox) acceptGhostCompletion() {971 func (o *Omnibox) acceptGhostCompletion() {
1053 o.mu.Lock()972 o.mu.Lock()
1054- if !o.hasGhostText || o.ghostFullText == "" {973+ fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)
974+ if !accepted {
1055 o.mu.Unlock()975 o.mu.Unlock()
1056 return976 return
1057 }977 }
1058- fullText := o.ghostFullText
1059 o.isAcceptingGhost = true978 o.isAcceptingGhost = true
1060 o.realInput = fullText979 o.realInput = fullText
980+ o.selectedIndex = -1
981+ o.hasNavigated = false
982+ o.ghostToken++
1061 o.ghostSuffix = ""983 o.ghostSuffix = ""
1062 o.ghostFullText = ""984 o.ghostFullText = ""
1063 o.hasGhostText = false985 o.hasGhostText = false
1064 o.mu.Unlock()986 o.mu.Unlock()
1065 987
1066- // Update entry text and clear ghost label988+ if o.entry != nil {
1067- o.entry.SetText(fullText)989+ if o.entry.GetText() != fullText {
1068- o.entry.SetPosition(-1) // Move cursor to end990+ o.entry.SetText(fullText)
1069-991+ }
1070- if o.ghostLabel != nil {992+ o.entry.SelectRegion(-1, -1)
1071- o.ghostLabel.SetVisible(false)993+ o.entry.SetPosition(-1)
994+ }
995+ if o.listBox != nil {
996+ o.listBox.SelectRow(nil)
1072 }997 }
1073 998
1074 o.mu.Lock()999 o.mu.Lock()
@@ -1076,6 +1001,13 @@ func (o *Omnibox) acceptGhostCompletion() {
1076 o.mu.Unlock()1001 o.mu.Unlock()
1077 }1002 }
1078 1003
1004+func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) {
1005+ if !hasGhost || ghostFullText == "" {
1006+ return "", false
1007+ }
1008+ return ghostFullText, true
1009+}
1010+
1079 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).1011 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
1080 // When a row is selected via arrow keys, show the URL as ghost text even if1012 // When a row is selected via arrow keys, show the URL as ghost text even if
1081 // the user's input isn't a prefix - this shows what Tab would fill in.1013 // the user's input isn't a prefix - this shows what Tab would fill in.
@@ -1179,19 +1111,83 @@ func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, sugges
1179 }1111 }
1180 }1112 }
1181 1113
1114+ o.mu.Lock()
1115+ o.ghostToken++
1116+ ghostToken := o.ghostToken
1117+ searchToken := o.searchToken
1118+ o.mu.Unlock()
1119+
1182 go func() {1120 go func() {
1183 suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{1121 suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1184 VisibleURLs: visibleURLs,1122 VisibleURLs: visibleURLs,
1185 AllowBangs: false,1123 AllowBangs: false,
1186 })1124 })
1125+ if !o.isGhostTokenCurrent(searchToken, ghostToken, entryText) {
1126+ return
1127+ }
1187 if suggestion == nil {1128 if suggestion == nil {
1188 o.clearGhostTextIfInput(entryText)1129 o.clearGhostTextIfInput(entryText)
1189 return1130 return
1190 }1131 }
1191- o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)1132+ fullText, suffix := normalizeGhostSuggestion(entryText, suggestion.FullText, suggestion.Suffix)
1133+ if fullText == "" || suffix == "" {
1134+ o.clearGhostTextIfInput(entryText)
1135+ return
1136+ }
1137+ o.setGhostText(entryText, suffix, fullText)
1192 }()1138 }()
1193 }1139 }
1194 1140
1141+// normalizeGhostSuggestion trims noisy URL completions to a domain completion when input
1142+// appears to be host-like (e.g. "google" -> "google.com" instead of full redirect URL).
1143+func normalizeGhostSuggestion(queryText, fullText, fallbackSuffix string) (normalizedFullText, suffix string) {
1144+ if queryText == "" || fullText == "" {
1145+ return "", ""
1146+ }
1147+
1148+ // If input already contains path/query-ish delimiters, keep original completion behavior.
1149+ if strings.ContainsAny(queryText, "/?#=& ") {
1150+ return fullText, fallbackSuffix
1151+ }
1152+
1153+ hostOnly := extractHostForCompletion(fullText)
1154+ if hostOnly != "" {
1155+ for _, candidate := range []string{hostOnly, strings.TrimPrefix(hostOnly, "www.")} {
1156+ if completionSuffix, ok := autocomplete.ComputeCompletionSuffix(queryText, candidate); ok {
1157+ return candidate, completionSuffix
1158+ }
1159+ }
1160+ }
1161+
1162+ return fullText, fallbackSuffix
1163+}
1164+
1165+func extractHostForCompletion(raw string) string {
1166+ if raw == "" {
1167+ return ""
1168+ }
1169+ if parsed, err := stdurl.Parse(raw); err == nil && parsed.Host != "" {
1170+ return strings.ToLower(parsed.Hostname())
1171+ }
1172+ trimmed := autocomplete.StripProtocol(raw)
1173+ trimmed = strings.ToLower(trimmed)
1174+ for _, sep := range []string{"/", "?", "#"} {
1175+ if idx := strings.Index(trimmed, sep); idx >= 0 {
1176+ trimmed = trimmed[:idx]
1177+ }
1178+ }
1179+ return strings.TrimSpace(trimmed)
1180+}
1181+
1182+func (o *Omnibox) isGhostTokenCurrent(searchToken, ghostToken uint64, query string) bool {
1183+ o.mu.RLock()
1184+ defer o.mu.RUnlock()
1185+ return o.visible &&
1186+ o.searchToken == searchToken &&
1187+ o.ghostToken == ghostToken &&
1188+ o.realInput == query
1189+}
1190+
1195 // performSearch executes the search based on current view mode and query.1191 // performSearch executes the search based on current view mode and query.
1196 func (o *Omnibox) performSearch() {1192 func (o *Omnibox) performSearch() {
1197 o.mu.RLock()1193 o.mu.RLock()
@@ -1215,6 +1211,11 @@ func (o *Omnibox) performSearch() {
1215 o.lastQuery = query1211 o.lastQuery = query
1216 o.debounceMu.Unlock()1212 o.debounceMu.Unlock()
1217 1213
1214+ o.mu.Lock()
1215+ o.searchToken++
1216+ token := o.searchToken
1217+ o.mu.Unlock()
1218+
1218 if strings.HasPrefix(query, "!") {1219 if strings.HasPrefix(query, "!") {
1219 o.updateBangDetection(query)1220 o.updateBangDetection(query)
1220 o.loadBangSuggestions(query)1221 o.loadBangSuggestions(query)
@@ -1224,23 +1225,23 @@ func (o *Omnibox) performSearch() {
1224 o.clearBangState()1225 o.clearBangState()
1225 1226
1226 if mode == ViewModeFavorites {1227 if mode == ViewModeFavorites {
1227- o.loadFavorites(query)1228+ o.loadFavorites(query, token)
1228 return1229 return
1229 }1230 }
1230 1231
1231 // History search1232 // History search
1232 if query == "" {1233 if query == "" {
1233- o.loadInitialHistory()1234+ o.loadInitialHistory(token)
1234 return1235 return
1235 }1236 }
1236 1237
1237 // Perform fuzzy history search in background1238 // Perform fuzzy history search in background
1238- o.searchHistory(query, o.effectiveMaxRows())1239+ o.searchHistory(query, o.effectiveMaxRows(), token)
1239 }1240 }
1240 1241
1241 // searchHistory runs a fuzzy history search in a background goroutine.1242 // searchHistory runs a fuzzy history search in a background goroutine.
1242 // query is the search text; limit caps the number of results.1243 // query is the search text; limit caps the number of results.
1243-func (o *Omnibox) searchHistory(query string, limit int) {1244+func (o *Omnibox) searchHistory(query string, limit int, token uint64) {
1244 go func() {1245 go func() {
1245 ctx := o.ctx1246 ctx := o.ctx
1246 log := logging.FromContext(ctx)1247 log := logging.FromContext(ctx)
@@ -1291,13 +1292,13 @@ func (o *Omnibox) searchHistory(query string, limit int) {
1291 })1292 })
1292 }1293 }
1293 1294
1294- // Marshal back to GTK main thread, passing query to ensure ghost text uses correct input1295+ // Marshal back to GTK main thread, passing query for stale-result guarding.
1295- o.idleAddUpdateSuggestions(suggestions, query)1296+ o.idleAddUpdateSuggestions(suggestions, query, token)
1296 }()1297 }()
1297 }1298 }
1298 1299
1299 // loadInitialHistory loads history based on InitialBehavior config.1300 // loadInitialHistory loads history based on InitialBehavior config.
1300-func (o *Omnibox) loadInitialHistory() {1301+func (o *Omnibox) loadInitialHistory(token uint64) {
1301 if o.historyUC == nil {1302 if o.historyUC == nil {
1302 return1303 return
1303 }1304 }
@@ -1312,7 +1313,7 @@ func (o *Omnibox) loadInitialHistory() {
1312 1313
1313 switch o.initialBehavior {1314 switch o.initialBehavior {
1314 case "none":1315 case "none":
1315- o.idleAddUpdateSuggestions(nil, "")1316+ o.idleAddUpdateSuggestions(nil, "", token)
1316 return1317 return
1317 1318
1318 case "most_visited", "recent", "":1319 case "most_visited", "recent", "":
@@ -1357,12 +1358,12 @@ func (o *Omnibox) loadInitialHistory() {
1357 }1358 }
1358 }1359 }
1359 1360
1360- o.idleAddUpdateSuggestions(suggestions, "")1361+ o.idleAddUpdateSuggestions(suggestions, "", token)
1361 }()1362 }()
1362 }1363 }
1363 1364
1364 // loadFavorites loads favorites, optionally filtered by query.1365 // loadFavorites loads favorites, optionally filtered by query.
1365-func (o *Omnibox) loadFavorites(query string) {1366+func (o *Omnibox) loadFavorites(query string, token uint64) {
1366 if o.favoritesUC == nil {1367 if o.favoritesUC == nil {
1367 return1368 return
1368 }1369 }
@@ -1395,7 +1396,7 @@ func (o *Omnibox) loadFavorites(query string) {
1395 })1396 })
1396 }1397 }
1397 1398
1398- o.idleAddUpdateFavorites(favorites, query)1399+ o.idleAddUpdateFavorites(favorites, query, token)
1399 }()1400 }()
1400 }1401 }
1401 1402
@@ -1976,6 +1977,7 @@ func (o *Omnibox) toggleFavorite() {
1976 log.Debug().Msg("remove favorite: invalid ID")1977 log.Debug().Msg("remove favorite: invalid ID")
1977 return1978 return
1978 }1979 }
1980+ entryText := o.entry.GetText()
1979 1981
1980 go func() {1982 go func() {
1981 ctx := o.ctx1983 ctx := o.ctx
@@ -2000,7 +2002,10 @@ func (o *Omnibox) toggleFavorite() {
2000 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")2002 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
2001 2003
2002 // Refresh favorites list and show toast2004 // Refresh favorites list and show toast
2003- o.loadFavorites(o.entry.GetText())2005+ o.mu.RLock()
2006+ currentToken := o.searchToken
2007+ o.mu.RUnlock()
2008+ o.loadFavorites(entryText, currentToken)
2004 if o.onToast != nil {2009 if o.onToast != nil {
2005 cb := glib.SourceFunc(func(_ uintptr) bool {2010 cb := glib.SourceFunc(func(_ uintptr) bool {
2006 o.onToast(ctx, "Favorite removed", ToastSuccess)2011 o.onToast(ctx, "Favorite removed", ToastSuccess)
@@ -2139,13 +2144,9 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2139 o.isAcceptingGhost = false2144 o.isAcceptingGhost = false
2140 o.mu.Unlock()2145 o.mu.Unlock()
2141 2146
2142- // Clear any stale ghost text display
2143- if o.ghostLabel != nil {
2144- o.ghostLabel.SetVisible(false)
2145- }
2146-
2147 // Set initial query2147 // Set initial query
2148 o.entry.SetText(query)2148 o.entry.SetText(query)
2149+ o.entry.SelectRegion(-1, -1)
2149 2150
2150 // Determine if we expect content initially2151 // Determine if we expect content initially
2151 // No content expected if: no query AND initialBehavior is "none"2152 // No content expected if: no query AND initialBehavior is "none"
@@ -2268,9 +2269,11 @@ func (o *Omnibox) UpdateZoomIndicator(factor float64) {
2268 }2269 }
2269 2270
2270 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.2271 // idleAddUpdateSuggestions schedules updateSuggestions on the GTK main thread.
2271-// The query parameter is passed through to ensure ghost text uses the correct input.2272+func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string, token uint64) {
2272-func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion, query string) {
2273 var cb glib.SourceFunc = func(data uintptr) bool {2273 var cb glib.SourceFunc = func(data uintptr) bool {
2274+ if !o.isSearchTokenCurrent(token) {
2275+ return false
2276+ }
2274 o.updateSuggestions(suggestions, query)2277 o.updateSuggestions(suggestions, query)
2275 return false // One-shot callback2278 return false // One-shot callback
2276 }2279 }
@@ -2293,15 +2296,23 @@ func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
2293 }2296 }
2294 2297
2295 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.2298 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
2296-// The query parameter is passed through to ensure ghost text uses the correct input.2299+func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string, token uint64) {
2297-func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite, query string) {
2298 var cb glib.SourceFunc = func(data uintptr) bool {2300 var cb glib.SourceFunc = func(data uintptr) bool {
2301+ if !o.isSearchTokenCurrent(token) {
2302+ return false
2303+ }
2299 o.updateFavorites(favorites, query)2304 o.updateFavorites(favorites, query)
2300 return false // One-shot callback, return false to remove source2305 return false // One-shot callback, return false to remove source
2301 }2306 }
2302 glib.IdleAdd(&cb, 0)2307 glib.IdleAdd(&cb, 0)
2303 }2308 }
2304 2309
2310+func (o *Omnibox) isSearchTokenCurrent(token uint64) bool {
2311+ o.mu.RLock()
2312+ defer o.mu.RUnlock()
2313+ return o.visible && o.searchToken == token
2314+}
2315+
2305 // Widget returns the omnibox widget for embedding in an overlay.2316 // Widget returns the omnibox widget for embedding in an overlay.
2306 func (o *Omnibox) Widget() *gtk.Widget {2317 func (o *Omnibox) Widget() *gtk.Widget {
2307 if o.outerBox == nil {2318 if o.outerBox == nil {
A internal/ui/component/omnibox_async_guard_test.go +42 -0
@@ -0,0 +1,42 @@
1+package component
2+
3+import "testing"
4+
5+func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
6+ o := &Omnibox{}
7+ o.visible = true
8+ o.searchToken = 3
9+
10+ if !o.isSearchTokenCurrent(3) {
11+ t.Fatalf("expected token to be current")
12+ }
13+ if o.isSearchTokenCurrent(2) {
14+ t.Fatalf("expected stale token to be rejected")
15+ }
16+
17+ o.visible = false
18+ if o.isSearchTokenCurrent(3) {
19+ t.Fatalf("expected hidden omnibox to reject token")
20+ }
21+}
22+
23+func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
24+ o := &Omnibox{}
25+ o.visible = true
26+ o.searchToken = 8
27+ o.ghostToken = 5
28+ o.realInput = "git"
29+
30+ if !o.isGhostTokenCurrent(8, 5, "git") {
31+ t.Fatalf("expected ghost token to be current")
32+ }
33+ if o.isGhostTokenCurrent(7, 5, "git") {
34+ t.Fatalf("expected stale search token to be rejected")
35+ }
36+ if o.isGhostTokenCurrent(8, 4, "git") {
37+ t.Fatalf("expected stale ghost token to be rejected")
38+ }
39+ if o.isGhostTokenCurrent(8, 5, "github") {
40+ t.Fatalf("expected query mismatch to be rejected")
41+ }
42+}
A internal/ui/component/omnibox_ghost_normalize_test.go +35 -0
@@ -0,0 +1,35 @@
1+package component
2+
3+import "testing"
4+
5+func TestNormalizeGhostSuggestion_PrefersHostOnlyForRedirectURL(t *testing.T) {
6+ full, suffix := normalizeGhostSuggestion(
7+ "google",
8+ "google.com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc",
9+ ".com/url?q=https://dashboard.stripe.com/auth_challenge/email_link/abc",
10+ )
11+ if full != "google.com" {
12+ t.Fatalf("expected host-only completion, got %q", full)
13+ }
14+ if suffix != ".com" {
15+ t.Fatalf("expected .com suffix, got %q", suffix)
16+ }
17+}
18+
19+func TestNormalizeGhostSuggestion_KeepsFallbackWhenInputLooksLikePath(t *testing.T) {
20+ fallback := "/url?q=https://x"
21+ full, suffix := normalizeGhostSuggestion("google.com/u", "google.com/url?q=https://x", fallback)
22+ if full != "google.com/url?q=https://x" {
23+ t.Fatalf("expected full URL fallback, got %q", full)
24+ }
25+ if suffix != fallback {
26+ t.Fatalf("expected fallback suffix, got %q", suffix)
27+ }
28+}
29+
30+func TestExtractHostForCompletion(t *testing.T) {
31+ got := extractHostForCompletion("https://WWW.Google.com/url?q=abc")
32+ if got != "www.google.com" {
33+ t.Fatalf("expected normalized host, got %q", got)
34+ }
35+}
A internal/ui/component/omnibox_ghost_state_test.go +111 -0
@@ -0,0 +1,111 @@
1+package component
2+
3+import "testing"
4+
5+func TestInlineGhostSelection(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ realInput string
9+ ghostFullText string
10+ selectionStart int
11+ selectionEnd int
12+ hasSelection bool
13+ want bool
14+ }{
15+ {
16+ name: "selected suffix matches typed prefix",
17+ realInput: "goo",
18+ ghostFullText: "google.com",
19+ selectionStart: 3,
20+ selectionEnd: 10,
21+ hasSelection: true,
22+ want: true,
23+ },
24+ {
25+ name: "no selection means no ghost suffix",
26+ realInput: "goo",
27+ ghostFullText: "google.com",
28+ selectionStart: 10,
29+ selectionEnd: 10,
30+ hasSelection: false,
31+ want: false,
32+ },
33+ {
34+ name: "collapsed selection should not be treated as ghost",
35+ realInput: "goo",
36+ ghostFullText: "google.com",
37+ selectionStart: 10,
38+ selectionEnd: 10,
39+ hasSelection: true,
40+ want: false,
41+ },
42+ {
43+ name: "wrong selection range should not be treated as ghost",
44+ realInput: "goo",
45+ ghostFullText: "google.com",
46+ selectionStart: 1,
47+ selectionEnd: 10,
48+ hasSelection: true,
49+ want: false,
50+ },
51+ }
52+
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ got := isInlineGhostSelection(
56+ tt.realInput,
57+ tt.ghostFullText,
58+ tt.selectionStart,
59+ tt.selectionEnd,
60+ tt.hasSelection,
61+ )
62+ if got != tt.want {
63+ t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want)
64+ }
65+ })
66+ }
67+}
68+
69+func TestAcceptedGhostInput(t *testing.T) {
70+ tests := []struct {
71+ name string
72+ hasGhost bool
73+ ghostFullText string
74+ wantText string
75+ wantAccepted bool
76+ }{
77+ {
78+ name: "accepts explicit ghost",
79+ hasGhost: true,
80+ ghostFullText: "google.com",
81+ wantText: "google.com",
82+ wantAccepted: true,
83+ },
84+ {
85+ name: "rejects without ghost",
86+ hasGhost: false,
87+ ghostFullText: "google.com",
88+ wantText: "",
89+ wantAccepted: false,
90+ },
91+ {
92+ name: "rejects empty ghost text",
93+ hasGhost: true,
94+ ghostFullText: "",
95+ wantText: "",
96+ wantAccepted: false,
97+ },
98+ }
99+
100+ for _, tt := range tests {
101+ t.Run(tt.name, func(t *testing.T) {
102+ gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText)
103+ if gotText != tt.wantText {
104+ t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText)
105+ }
106+ if gotAccepted != tt.wantAccepted {
107+ t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted)
108+ }
109+ })
110+ }
111+}
M internal/ui/component/pane_view.go +25 -4
@@ -29,9 +29,10 @@ type PaneView struct {
29 paneID entity.PaneID29 paneID entity.PaneID
30 isActive bool30 isActive bool
31 31
32- onFocusIn func(paneID entity.PaneID)32+ onFocusIn func(paneID entity.PaneID)
33- onFocusOut func(paneID entity.PaneID)33+ onFocusOut func(paneID entity.PaneID)
34- onHover func(paneID entity.PaneID)34+ onHover func(paneID entity.PaneID)
35+ onMouseMotion func()
35 36
36 hoverHandler *input.HoverHandler37 hoverHandler *input.HoverHandler
37 38
@@ -218,6 +219,15 @@ func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
218 pv.onHover = fn219 pv.onHover = fn
219 }220 }
220 221
222+// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
223+// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
224+func (pv *PaneView) SetOnMouseMotion(fn func()) {
225+ pv.mu.Lock()
226+ defer pv.mu.Unlock()
227+
228+ pv.onMouseMotion = fn
229+}
230+
221 // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.231 // AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
222 func (pv *PaneView) AttachHoverHandler(ctx context.Context) {232 func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
223 pv.mu.Lock()233 pv.mu.Lock()
@@ -226,7 +236,7 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
226 // Create hover handler236 // Create hover handler
227 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)237 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
228 238
229- // Wire up callback239+ // Wire up hover enter callback
230 pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {240 pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
231 pv.mu.RLock()241 pv.mu.RLock()
232 callback := pv.onHover242 callback := pv.onHover
@@ -237,6 +247,17 @@ func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
237 }247 }
238 })248 })
239 249
250+ // Wire up mouse motion callback (intentional movement detection)
251+ pv.hoverHandler.SetOnMotion(func() {
252+ pv.mu.RLock()
253+ callback := pv.onMouseMotion
254+ pv.mu.RUnlock()
255+
256+ if callback != nil {
257+ callback()
258+ }
259+ })
260+
240 // Attach to overlay widget261 // Attach to overlay widget
241 gtkWidget := pv.overlay.GtkWidget()262 gtkWidget := pv.overlay.GtkWidget()
242 if gtkWidget != nil {263 if gtkWidget != nil {
M internal/ui/component/pane_view_internal_test.go +21 -10
@@ -15,6 +15,7 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
15 mockContent := mocks.NewMockBoxWidget(t)15 mockContent := mocks.NewMockBoxWidget(t)
16 mockSpinner := mocks.NewMockSpinnerWidget(t)16 mockSpinner := mocks.NewMockSpinnerWidget(t)
17 mockLogo := mocks.NewMockImageWidget(t)17 mockLogo := mocks.NewMockImageWidget(t)
18+ mockVersion := mocks.NewMockLabelWidget(t)
18 19
19 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()20 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
20 mockContainer.EXPECT().SetHexpand(true).Once()21 mockContainer.EXPECT().SetHexpand(true).Once()
@@ -32,26 +33,36 @@ func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
32 mockContent.EXPECT().SetCanTarget(false).Once()33 mockContent.EXPECT().SetCanTarget(false).Once()
33 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()34 mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
34 35
35- mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
36- mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
37- mockSpinner.EXPECT().SetValign(mock.Anything).Once()
38- mockSpinner.EXPECT().SetCanFocus(false).Once()
39- mockSpinner.EXPECT().SetCanTarget(false).Once()
40- mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
41- mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
42-
43 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()36 mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
44 mockLogo.EXPECT().SetHalign(mock.Anything).Once()37 mockLogo.EXPECT().SetHalign(mock.Anything).Once()
45 mockLogo.EXPECT().SetValign(mock.Anything).Once()38 mockLogo.EXPECT().SetValign(mock.Anything).Once()
46 mockLogo.EXPECT().SetCanFocus(false).Once()39 mockLogo.EXPECT().SetCanFocus(false).Once()
47 mockLogo.EXPECT().SetCanTarget(false).Once()40 mockLogo.EXPECT().SetCanTarget(false).Once()
48- mockLogo.EXPECT().SetSizeRequest(512, 512).Once()41+ mockLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once()
49- mockLogo.EXPECT().SetPixelSize(512).Once()42+ mockLogo.EXPECT().SetPixelSize(mock.Anything).Once()
50 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()43 mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
51 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()44 mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
52 45
46+ mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
47+ mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
48+ mockSpinner.EXPECT().SetValign(mock.Anything).Once()
49+ mockSpinner.EXPECT().SetCanFocus(false).Once()
50+ mockSpinner.EXPECT().SetCanTarget(false).Once()
51+ mockSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Once()
52+ mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
53+
54+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockVersion).Once()
55+ mockVersion.EXPECT().SetHalign(mock.Anything).Once()
56+ mockVersion.EXPECT().SetValign(mock.Anything).Once()
57+ mockVersion.EXPECT().SetCanFocus(false).Once()
58+ mockVersion.EXPECT().SetCanTarget(false).Once()
59+ mockVersion.EXPECT().SetMaxWidthChars(mock.Anything).Once()
60+ mockVersion.EXPECT().SetEllipsize(mock.Anything).Once()
61+ mockVersion.EXPECT().AddCssClass("loading-skeleton-version").Once()
62+
53 mockContent.EXPECT().Append(mockLogo).Once()63 mockContent.EXPECT().Append(mockLogo).Once()
54 mockContent.EXPECT().Append(mockSpinner).Once()64 mockContent.EXPECT().Append(mockSpinner).Once()
65+ mockContent.EXPECT().Append(mockVersion).Once()
55 mockContainer.EXPECT().Append(mockContent).Once()66 mockContainer.EXPECT().Append(mockContent).Once()
56 67
57 mockContainer.EXPECT().SetVisible(true).Once()68 mockContainer.EXPECT().SetVisible(true).Once()
M internal/ui/component/pane_view_test.go +26 -13
@@ -34,7 +34,7 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
34 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background34 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
35 mockOverlay.EXPECT().SetChild(mockWebView).Once()35 mockOverlay.EXPECT().SetChild(mockWebView).Once()
36 36
37- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)37+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
38 38
39 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()39 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
40 mockBorderBox.EXPECT().SetCanFocus(false).Once()40 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -75,7 +75,7 @@ func TestNewPaneView_NilWebView(t *testing.T) {
75 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background75 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
76 // SetChild should NOT be called when webview is nil76 // SetChild should NOT be called when webview is nil
77 77
78- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)78+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
79 79
80 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()80 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
81 mockBorderBox.EXPECT().SetCanFocus(false).Once()81 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -509,6 +509,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
509 509
510 // Helper function to setup common mock expectations for PaneView creation510 // Helper function to setup common mock expectations for PaneView creation
511 func setupLoadingSkeletonMocks(511 func setupLoadingSkeletonMocks(
512+ t *testing.T,
512 mockFactory *mocks.MockWidgetFactory,513 mockFactory *mocks.MockWidgetFactory,
513 mockOverlay *mocks.MockOverlayWidget,514 mockOverlay *mocks.MockOverlayWidget,
514 mockLoadingContainer *mocks.MockBoxWidget,515 mockLoadingContainer *mocks.MockBoxWidget,
@@ -516,6 +517,8 @@ func setupLoadingSkeletonMocks(
516 mockLoadingSpinner *mocks.MockSpinnerWidget,517 mockLoadingSpinner *mocks.MockSpinnerWidget,
517 mockLoadingLogo *mocks.MockImageWidget,518 mockLoadingLogo *mocks.MockImageWidget,
518 ) {519 ) {
520+ mockLoadingVersion := mocks.NewMockLabelWidget(t)
521+
519 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()522 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
520 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()523 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
521 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()524 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
@@ -533,6 +536,16 @@ func setupLoadingSkeletonMocks(
533 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()536 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
534 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()537 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
535 538
539+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
540+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
541+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
542+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
543+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
544+ mockLoadingLogo.EXPECT().SetSizeRequest(256, 256).Maybe()
545+ mockLoadingLogo.EXPECT().SetPixelSize(256).Maybe()
546+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
547+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
548+
536 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()549 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
537 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()550 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
538 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()551 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
@@ -542,18 +555,18 @@ func setupLoadingSkeletonMocks(
542 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()555 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
543 mockLoadingSpinner.EXPECT().Start().Maybe()556 mockLoadingSpinner.EXPECT().Start().Maybe()
544 557
545- mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()558+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once()
546- mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()559+ mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe()
547- mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()560+ mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe()
548- mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()561+ mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe()
549- mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()562+ mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe()
550- mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()563+ mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
551- mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()564+ mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe()
552- mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()565+ mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe()
553- mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
554 566
555 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()567 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
556 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()568 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
569+ mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe()
557 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()570 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
558 571
559 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()572 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -581,7 +594,7 @@ func setupPaneViewMocks(
581 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background594 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
582 mockOverlay.EXPECT().SetChild(mockWebView).Once()595 mockOverlay.EXPECT().SetChild(mockWebView).Once()
583 596
584- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)597+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
585 598
586 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()599 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
587 mockBorderBox.EXPECT().SetCanFocus(false).Once()600 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -611,7 +624,7 @@ func setupPaneViewMocksNoWebView(
611 mockOverlay.EXPECT().SetVisible(true).Once()624 mockOverlay.EXPECT().SetVisible(true).Once()
612 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background625 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
613 626
614- setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)627+ setupLoadingSkeletonMocks(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
615 628
616 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()629 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
617 mockBorderBox.EXPECT().SetCanFocus(false).Once()630 mockBorderBox.EXPECT().SetCanFocus(false).Once()
M internal/ui/component/workspace_view.go +26 -1
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "sync"8 "sync"
9+ "sync/atomic"
9 "time"10 "time"
10 11
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -56,6 +57,14 @@ type WorkspaceView struct {
56 // Prevents hover focus from overriding keyboard-initiated focus changes57 // Prevents hover focus from overriding keyboard-initiated focus changes
57 hoverSuppressedUntil time.Time58 hoverSuppressedUntil time.Time
58 59
60+ // newPaneFocusLock suppresses hover focus after a new pane is created.
61+ // Rule: new pane creation always wins focus. Hover focus is blocked
62+ // until the user intentionally moves the mouse (motion event), at which
63+ // point normal hover-follows-mouse behavior resumes. This prevents
64+ // GTK's synthetic enter events (fired on widget rearrangement) from
65+ // stealing focus away from a freshly created pane.
66+ newPaneFocusLock atomic.Bool
67+
59 // Auto-open omnibox on new pane creation68 // Auto-open omnibox on new pane creation
60 autoOpenOnNewPane bool69 autoOpenOnNewPane bool
61 70
@@ -93,6 +102,13 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
93 }102 }
94 })103 })
95 104
105+ // Clear new-pane focus lock on intentional mouse motion.
106+ // This allows hover-follows-mouse to resume after the user moves the
107+ // mouse, while keeping focus on the new pane until that happens.
108+ pv.SetOnMouseMotion(func() {
109+ a.wv.newPaneFocusLock.Store(false)
110+ })
111+
96 // Set up hover callback for focus-follows-mouse112 // Set up hover callback for focus-follows-mouse
97 pv.SetOnHover(func(paneID entity.PaneID) {113 pv.SetOnHover(func(paneID entity.PaneID) {
98 // Skip if hover is suppressed (keyboard navigation just happened)114 // Skip if hover is suppressed (keyboard navigation just happened)
@@ -100,6 +116,12 @@ func (a *paneViewFactoryAdapter) CreatePaneView(node *entity.PaneNode) layout.Wi
100 return116 return
101 }117 }
102 118
119+ // Skip if new pane was just created — focus stays on new pane
120+ // until the user intentionally moves the mouse (see SetOnMouseMotion).
121+ if a.wv.newPaneFocusLock.Load() {
122+ return
123+ }
124+
103 // Skip if this pane is already active125 // Skip if this pane is already active
104 if a.wv.GetActivePaneID() == paneID {126 if a.wv.GetActivePaneID() == paneID {
105 return127 return
@@ -566,7 +588,10 @@ func (wv *WorkspaceView) SetAutoOpenOnNewPane(enabled bool) {
566 // NotifyNewPaneCreated handles new pane creation events.588 // NotifyNewPaneCreated handles new pane creation events.
567 // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.589 // Suppresses hover focus to keep focus on the new pane, and optionally shows omnibox.
568 func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {590 func (wv *WorkspaceView) NotifyNewPaneCreated(ctx context.Context) {
569- // Suppress hover focus so mouse position doesn't steal focus from new pane591+ // Lock hover focus until the user intentionally moves the mouse.
592+ // New pane creation always wins focus; hover-follows-mouse resumes
593+ // only after real mouse motion (cleared by PaneView.SetOnMouseMotion).
594+ wv.newPaneFocusLock.Store(true)
570 wv.SuppressHover(KeyboardFocusSuppressDuration)595 wv.SuppressHover(KeyboardFocusSuppressDuration)
571 wv.CancelAllPendingHovers()596 wv.CancelAllPendingHovers()
572 597
M internal/ui/component/workspace_view_test.go +27 -14
@@ -15,6 +15,7 @@ import (
15 )15 )
16 16
17 func setupLoadingSkeletonMocksWorkspace(17 func setupLoadingSkeletonMocksWorkspace(
18+ t *testing.T,
18 mockFactory *mocks.MockWidgetFactory,19 mockFactory *mocks.MockWidgetFactory,
19 mockOverlay *mocks.MockOverlayWidget,20 mockOverlay *mocks.MockOverlayWidget,
20 mockLoadingContainer *mocks.MockBoxWidget,21 mockLoadingContainer *mocks.MockBoxWidget,
@@ -22,6 +23,8 @@ func setupLoadingSkeletonMocksWorkspace(
22 mockLoadingSpinner *mocks.MockSpinnerWidget,23 mockLoadingSpinner *mocks.MockSpinnerWidget,
23 mockLoadingLogo *mocks.MockImageWidget,24 mockLoadingLogo *mocks.MockImageWidget,
24 ) {25 ) {
26+ mockLoadingVersion := mocks.NewMockLabelWidget(t)
27+
25 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()28 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
26 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()29 mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
27 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()30 mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
@@ -39,27 +42,37 @@ func setupLoadingSkeletonMocksWorkspace(
39 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()42 mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
40 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()43 mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
41 44
45+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
46+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
47+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
48+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
49+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
50+ mockLoadingLogo.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
51+ mockLoadingLogo.EXPECT().SetPixelSize(mock.Anything).Maybe()
52+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
53+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
54+
42 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()55 mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
43 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()56 mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
44 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()57 mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
45 mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()58 mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
46 mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()59 mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
47- mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()60+ mockLoadingSpinner.EXPECT().SetSizeRequest(mock.Anything, mock.Anything).Maybe()
48 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()61 mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
49 mockLoadingSpinner.EXPECT().Start().Maybe()62 mockLoadingSpinner.EXPECT().Start().Maybe()
50 63
51- mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()64+ mockFactory.EXPECT().NewLabel(mock.Anything).Return(mockLoadingVersion).Once()
52- mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()65+ mockLoadingVersion.EXPECT().SetHalign(mock.Anything).Maybe()
53- mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()66+ mockLoadingVersion.EXPECT().SetValign(mock.Anything).Maybe()
54- mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()67+ mockLoadingVersion.EXPECT().SetCanFocus(false).Maybe()
55- mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()68+ mockLoadingVersion.EXPECT().SetCanTarget(false).Maybe()
56- mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()69+ mockLoadingVersion.EXPECT().SetMaxWidthChars(mock.Anything).Maybe()
57- mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()70+ mockLoadingVersion.EXPECT().SetEllipsize(mock.Anything).Maybe()
58- mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()71+ mockLoadingVersion.EXPECT().AddCssClass("loading-skeleton-version").Maybe()
59- mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
60 72
61 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()73 mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
62 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()74 mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
75+ mockLoadingContent.EXPECT().Append(mockLoadingVersion).Maybe()
63 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()76 mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
64 77
65 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()78 mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
@@ -101,7 +114,7 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
101 mockOverlay.EXPECT().SetVisible(true).Once()114 mockOverlay.EXPECT().SetVisible(true).Once()
102 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()115 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
103 116
104- setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)117+ setupLoadingSkeletonMocksWorkspace(t, mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
105 118
106 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()119 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
107 mockBorderBox.EXPECT().SetCanFocus(false).Once()120 mockBorderBox.EXPECT().SetCanFocus(false).Once()
@@ -166,7 +179,7 @@ func setupStackedLeafMocks(
166 mockTitleBar.EXPECT().AddController(mock.Anything).Once()179 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
167 180
168 // Close button click handler181 // Close button click handler
169- mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()182+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
170 183
171 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests184 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
172 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()185 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
@@ -274,9 +287,9 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
274 mockStackBox2.EXPECT().SetVisible(true).Once()287 mockStackBox2.EXPECT().SetVisible(true).Once()
275 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()288 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
276 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()289 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
277- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()290+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
278 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()291 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
279- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()292+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
280 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()293 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
281 294
282 mockBox.EXPECT().Append(mockPaned).Once()295 mockBox.EXPECT().Append(mockPaned).Once()
M internal/ui/coordinator/content.go +353 -103
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7+ "net/url"
7 "strings"8 "strings"
8 "sync"9 "sync"
9 "time"10 "time"
@@ -20,12 +21,16 @@ import (
20 "github.com/bnema/dumber/internal/ui/component"21 "github.com/bnema/dumber/internal/ui/component"
21 "github.com/bnema/dumber/internal/ui/input"22 "github.com/bnema/dumber/internal/ui/input"
22 "github.com/bnema/dumber/internal/ui/layout"23 "github.com/bnema/dumber/internal/ui/layout"
24+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
23 "github.com/jwijenbergh/puregotk/v4/gdk"25 "github.com/jwijenbergh/puregotk/v4/gdk"
26+ "github.com/jwijenbergh/puregotk/v4/glib"
24 )27 )
25 28
26 const (29 const (
27- aboutBlankURI = "about:blank"30+ aboutBlankURI = "about:blank"
28- logURLMaxLen = 8031+ crashPageURI = "dumb://home/crash"
32+ logURLMaxLen = 80
33+ oauthParentRefreshDebounce = 200 * time.Millisecond
29 34
30 // Dark theme background color (#0a0a0b) as float32 RGBA values35 // Dark theme background color (#0a0a0b) as float32 RGBA values
31 darkBgR = 0.03936 darkBgR = 0.039
@@ -34,6 +39,51 @@ const (
34 darkBgA = 1.039 darkBgA = 1.0
35 )40 )
36 41
42+func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool {
43+ switch reason {
44+ case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue:
45+ return true
46+ case webkitlib.WebProcessTerminatedByApiValue:
47+ return false
48+ default:
49+ return true
50+ }
51+}
52+
53+func extractOriginalURIFromCrashPage(uri string) string {
54+ if uri == "" {
55+ return ""
56+ }
57+
58+ parsed, err := url.Parse(uri)
59+ if err != nil {
60+ return uri
61+ }
62+
63+ if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath {
64+ return uri
65+ }
66+
67+ if strings.Trim(parsed.Path, "/") != "crash" {
68+ return uri
69+ }
70+
71+ original := strings.TrimSpace(parsed.Query().Get("url"))
72+ if original == "" {
73+ return ""
74+ }
75+ return original
76+}
77+
78+func buildCrashPageURI(originalURI string) string {
79+ if strings.TrimSpace(originalURI) == "" {
80+ return crashPageURI
81+ }
82+ query := url.Values{}
83+ query.Set("url", originalURI)
84+ return crashPageURI + "?" + query.Encode()
85+}
86+
37 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.87 // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
38 type ContentCoordinator struct {88 type ContentCoordinator struct {
39 pool *webkit.WebViewPool89 pool *webkit.WebViewPool
@@ -43,6 +93,7 @@ type ContentCoordinator struct {
43 injector *webkit.ContentInjector93 injector *webkit.ContentInjector
44 94
45 webViews map[entity.PaneID]*webkit.WebView95 webViews map[entity.PaneID]*webkit.WebView
96+ webViewsMu sync.RWMutex
46 paneTitles map[entity.PaneID]string97 paneTitles map[entity.PaneID]string
47 titleMu sync.RWMutex98 titleMu sync.RWMutex
48 99
@@ -87,6 +138,8 @@ type ContentCoordinator struct {
87 factory *webkit.WebViewFactory138 factory *webkit.WebViewFactory
88 popupConfig *config.PopupBehaviorConfig139 popupConfig *config.PopupBehaviorConfig
89 pendingPopups map[port.WebViewID]*PendingPopup140 pendingPopups map[port.WebViewID]*PendingPopup
141+ popupOAuth map[port.WebViewID]*popupOAuthState
142+ popupRefresh map[entity.PaneID]*time.Timer
90 popupMu sync.RWMutex143 popupMu sync.RWMutex
91 144
92 // Callback to insert popup into workspace (avoids circular dependency)145 // Callback to insert popup into workspace (avoids circular dependency)
@@ -117,6 +170,14 @@ type pendingThemeUpdate struct {
117 cssText string170 cssText string
118 }171 }
119 172
173+type popupOAuthState struct {
174+ ParentPaneID entity.PaneID
175+ CallbackURI string
176+ Success bool
177+ Error bool
178+ Seen bool
179+}
180+
120 // NewContentCoordinator creates a new ContentCoordinator.181 // NewContentCoordinator creates a new ContentCoordinator.
121 func NewContentCoordinator(182 func NewContentCoordinator(
122 ctx context.Context,183 ctx context.Context,
@@ -140,6 +201,8 @@ func NewContentCoordinator(
140 pendingReveal: make(map[entity.PaneID]bool),201 pendingReveal: make(map[entity.PaneID]bool),
141 getActiveWS: getActiveWS,202 getActiveWS: getActiveWS,
142 pendingPopups: make(map[port.WebViewID]*PendingPopup),203 pendingPopups: make(map[port.WebViewID]*PendingPopup),
204+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
205+ popupRefresh: make(map[entity.PaneID]*time.Timer),
143 }206 }
144 }207 }
145 208
@@ -199,7 +262,7 @@ func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
199 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {262 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
200 log := logging.FromContext(ctx)263 log := logging.FromContext(ctx)
201 264
202- if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {265+ if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
203 return wv, nil266 return wv, nil
204 }267 }
205 268
@@ -208,7 +271,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
208 }271 }
209 272
210 // Mark tab_created on first webview (first tab)273 // Mark tab_created on first webview (first tab)
211- if len(c.webViews) == 0 {274+ if c.webViewCount() == 0 {
212 logging.Trace().Mark("tab_created")275 logging.Trace().Mark("tab_created")
213 }276 }
214 277
@@ -218,7 +281,7 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
218 }281 }
219 logging.Trace().Mark("webview_acquired")282 logging.Trace().Mark("webview_acquired")
220 283
221- c.webViews[paneID] = wv284+ c.setWebViewLocked(paneID, wv)
222 c.setupWebViewCallbacks(ctx, paneID, wv)285 c.setupWebViewCallbacks(ctx, paneID, wv)
223 286
224 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")287 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
@@ -229,11 +292,10 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
229 func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {292 func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
230 log := logging.FromContext(ctx)293 log := logging.FromContext(ctx)
231 294
232- wv, ok := c.webViews[paneID]295+ wv := c.deleteWebViewLocked(paneID)
233- if !ok || wv == nil {296+ if wv == nil {
234 return297 return
235 }298 }
236- delete(c.webViews, paneID)
237 c.clearPendingAppearance(paneID)299 c.clearPendingAppearance(paneID)
238 300
239 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),301 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
@@ -360,28 +422,64 @@ func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView
360 return nil422 return nil
361 }423 }
362 424
363- return c.webViews[pane.Pane.ID]425+ return c.getWebViewLocked(pane.Pane.ID)
364 }426 }
365 427
366 // GetWebView returns the WebView for a specific pane.428 // GetWebView returns the WebView for a specific pane.
367 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {429 func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
368- return c.webViews[paneID]430+ return c.getWebViewLocked(paneID)
369 }431 }
370 432
371 // RegisterPopupWebView registers a popup WebView that was created externally.433 // RegisterPopupWebView registers a popup WebView that was created externally.
372 // This is used when popup tabs are created and the WebView needs to be tracked.434 // This is used when popup tabs are created and the WebView needs to be tracked.
373 func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {435 func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
374 if wv != nil && paneID != "" {436 if wv != nil && paneID != "" {
375- c.webViews[paneID] = wv437+ c.setWebViewLocked(paneID, wv)
376 }438 }
377 }439 }
378 440
441+func (c *ContentCoordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView {
442+ c.webViewsMu.RLock()
443+ defer c.webViewsMu.RUnlock()
444+ return c.webViews[paneID]
445+}
446+
447+func (c *ContentCoordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) {
448+ c.webViewsMu.Lock()
449+ c.webViews[paneID] = wv
450+ c.webViewsMu.Unlock()
451+}
452+
453+func (c *ContentCoordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView {
454+ c.webViewsMu.Lock()
455+ defer c.webViewsMu.Unlock()
456+ wv := c.webViews[paneID]
457+ delete(c.webViews, paneID)
458+ return wv
459+}
460+
461+func (c *ContentCoordinator) webViewCount() int {
462+ c.webViewsMu.RLock()
463+ defer c.webViewsMu.RUnlock()
464+ return len(c.webViews)
465+}
466+
467+func (c *ContentCoordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView {
468+ c.webViewsMu.RLock()
469+ defer c.webViewsMu.RUnlock()
470+ snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews))
471+ for paneID, wv := range c.webViews {
472+ snapshot[paneID] = wv
473+ }
474+ return snapshot
475+}
476+
379 // ApplyFiltersToAll applies content filters to all active webviews.477 // ApplyFiltersToAll applies content filters to all active webviews.
380 // Called when filters become available after webviews were already created.478 // Called when filters become available after webviews were already created.
381 func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {479 func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
382 log := logging.FromContext(ctx)480 log := logging.FromContext(ctx)
383 481
384- for paneID, wv := range c.webViews {482+ for paneID, wv := range c.snapshotWebViews() {
385 if wv != nil && !wv.IsDestroyed() {483 if wv != nil && !wv.IsDestroyed() {
386 applier.ApplyTo(ctx, wv.UserContentManager())484 applier.ApplyTo(ctx, wv.UserContentManager())
387 log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")485 log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
@@ -396,7 +494,7 @@ func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.
396 return494 return
397 }495 }
398 496
399- for paneID, wv := range c.webViews {497+ for paneID, wv := range c.snapshotWebViews() {
400 if wv == nil || wv.IsDestroyed() {498 if wv == nil || wv.IsDestroyed() {
401 continue499 continue
402 }500 }
@@ -740,10 +838,10 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
740 838
741 // Notify history persistence (get URL from WebView)839 // Notify history persistence (get URL from WebView)
742 if c.onTitleUpdated != nil {840 if c.onTitleUpdated != nil {
743- if wv := c.webViews[paneID]; wv != nil {841+ if wv := c.getWebViewLocked(paneID); wv != nil {
744- url := wv.URI()842+ currentURI := wv.URI()
745- if url != "" && title != "" {843+ if currentURI != "" && title != "" {
746- c.onTitleUpdated(ctx, paneID, url, title)844+ c.onTitleUpdated(ctx, paneID, currentURI, title)
747 }845 }
748 }846 }
749 }847 }
@@ -782,12 +880,28 @@ func (c *ContentCoordinator) updateStackedPaneTitle(
782 }880 }
783 }881 }
784 882
883+// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
884+// Called from onLoadCommitted to keep titles in sync during navigation.
885+func (c *ContentCoordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
886+ _, wsView := c.getActiveWS()
887+ if wsView == nil {
888+ return
889+ }
890+ tr := wsView.TreeRenderer()
891+ if tr == nil {
892+ return
893+ }
894+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
895+ c.updateStackedPaneTitle(ctx, sv, paneID, title)
896+ }
897+}
898+
785 // onFaviconChanged updates favicon tracking when a WebView's favicon changes.899 // onFaviconChanged updates favicon tracking when a WebView's favicon changes.
786 func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {900 func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
787 log := logging.FromContext(ctx)901 log := logging.FromContext(ctx)
788 902
789 // Get current URI to extract domain for caching903 // Get current URI to extract domain for caching
790- wv := c.webViews[paneID]904+ wv := c.getWebViewLocked(paneID)
791 if wv == nil {905 if wv == nil {
792 return906 return
793 }907 }
@@ -851,27 +965,25 @@ func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
851 // SetNavigationOrigin records the original URL before navigation starts.965 // SetNavigationOrigin records the original URL before navigation starts.
852 // This allows caching favicons under both original and final domains966 // This allows caching favicons under both original and final domains
853 // when cross-domain redirects occur (e.g., google.fr → google.com).967 // when cross-domain redirects occur (e.g., google.fr → google.com).
854-func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {968+func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
855 c.navOriginMu.Lock()969 c.navOriginMu.Lock()
856- c.navOrigins[paneID] = url970+ c.navOrigins[paneID] = uri
857 c.navOriginMu.Unlock()971 c.navOriginMu.Unlock()
858 }972 }
859 973
860 // PreloadCachedFavicon checks the favicon cache and updates the stacked pane974 // PreloadCachedFavicon checks the favicon cache and updates the stacked pane
861 // title bar immediately if a cached favicon exists for the URL.975 // title bar immediately if a cached favicon exists for the URL.
862 // This provides instant favicon display without waiting for WebKit.976 // This provides instant favicon display without waiting for WebKit.
863-func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {977+func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
864- if c.faviconAdapter == nil || url == "" {978+ if c.faviconAdapter == nil || uri == "" {
865 return979 return
866 }980 }
867 981
868 // Check memory and disk cache (no external fetch)982 // Check memory and disk cache (no external fetch)
869- texture := c.faviconAdapter.PreloadFromCache(url)983+ texture := c.faviconAdapter.PreloadFromCache(uri)
870- if texture == nil {
871- return
872- }
873 984
874- // Update stacked pane favicon if applicable985+ // Update stacked pane favicon if applicable.
986+ // A nil texture triggers the default icon fallback, which avoids stale favicons.
875 _, wsView := c.getActiveWS()987 _, wsView := c.getActiveWS()
876 if wsView != nil {988 if wsView != nil {
877 tr := wsView.TreeRenderer()989 tr := wsView.TreeRenderer()
@@ -892,14 +1004,14 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
892 log := logging.FromContext(ctx)1004 log := logging.FromContext(ctx)
893 logging.Trace().Mark("load_committed")1005 logging.Trace().Mark("load_committed")
894 1006
895- url := wv.URI()1007+ uri := wv.URI()
896- if url == "" {1008+ if uri == "" {
897 return1009 return
898 }1010 }
899 1011
900 // Set appropriate background color based on page type to prevent dark background bleeding.1012 // Set appropriate background color based on page type to prevent dark background bleeding.
901 switch {1013 switch {
902- case strings.HasPrefix(url, "dumb://"):1014+ case strings.HasPrefix(uri, "dumb://"):
903 // Internal pages: apply themed background1015 // Internal pages: apply themed background
904 theme, ok := c.getCurrentTheme()1016 theme, ok := c.getCurrentTheme()
905 if ok && theme.prefersDark {1017 if ok && theme.prefersDark {
@@ -907,7 +1019,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
907 } else {1019 } else {
908 wv.ResetBackgroundToDefault()1020 wv.ResetBackgroundToDefault()
909 }1021 }
910- case strings.HasPrefix(url, "about:"):1022+ case strings.HasPrefix(uri, "about:"):
911 // Keep pool background (no action)1023 // Keep pool background (no action)
912 default:1024 default:
913 // External pages: white background1025 // External pages: white background
@@ -919,7 +1031,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
919 // Skip showing if this is about:blank but the pane is loading a different URL1031 // Skip showing if this is about:blank but the pane is loading a different URL
920 // This prevents the brief flash of about:blank during initial navigation1032 // This prevents the brief flash of about:blank during initial navigation
921 shouldShow := true1033 shouldShow := true
922- if url == aboutBlankURI {1034+ if uri == aboutBlankURI {
923 // Get the pane's intended URI from the workspace1035 // Get the pane's intended URI from the workspace
924 ws, _ := c.getActiveWS()1036 ws, _ := c.getActiveWS()
925 if ws != nil {1037 if ws != nil {
@@ -950,15 +1062,22 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
950 1062
951 c.markPendingReveal(paneID)1063 c.markPendingReveal(paneID)
952 if wv.EstimatedProgress() > 0 {1064 if wv.EstimatedProgress() > 0 {
953- c.revealIfPending(ctx, paneID, url, "progress-after-commit")1065+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
954 }1066 }
955 1067
956 // Update domain model with current URI for session snapshots1068 // Update domain model with current URI for session snapshots
957- c.updatePaneURI(paneID, url)1069+ c.updatePaneURI(paneID, uri)
1070+
1071+ // Sync StackedView title bar with the WebView's current title.
1072+ // This keeps the stacked title bar up-to-date immediately on navigation,
1073+ // before the asynchronous notify::title signal fires.
1074+ if title := wv.Title(); title != "" {
1075+ c.syncStackedTitle(ctx, paneID, title)
1076+ }
958 1077
959 // Record history - URI is guaranteed to be correct at LoadCommitted1078 // Record history - URI is guaranteed to be correct at LoadCommitted
960 if c.onHistoryRecord != nil {1079 if c.onHistoryRecord != nil {
961- c.onHistoryRecord(ctx, paneID, url)1080+ c.onHistoryRecord(ctx, paneID, uri)
962 }1081 }
963 1082
964 // Apply zoom1083 // Apply zoom
@@ -966,7 +1085,7 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
966 return1085 return
967 }1086 }
968 1087
969- domain, err := usecase.ExtractDomain(url)1088+ domain, err := usecase.ExtractDomain(uri)
970 if err != nil {1089 if err != nil {
971 return1090 return
972 }1091 }
@@ -997,24 +1116,21 @@ func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID
997 1116
998 // onSPANavigation records history when URL changes via JavaScript (History API).1117 // onSPANavigation records history when URL changes via JavaScript (History API).
999 // This handles SPA navigation like YouTube search, where the URL changes without a page load.1118 // This handles SPA navigation like YouTube search, where the URL changes without a page load.
1000-func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {1119+func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
1001- log := logging.FromContext(ctx)
1002- log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
1003-
1004 // Update domain model with current URI for session snapshots1120 // Update domain model with current URI for session snapshots
1005- c.updatePaneURI(paneID, url)1121+ c.updatePaneURI(paneID, uri)
1006 1122
1007 // Record history for SPA navigation1123 // Record history for SPA navigation
1008 if c.onHistoryRecord != nil {1124 if c.onHistoryRecord != nil {
1009- c.onHistoryRecord(ctx, paneID, url)1125+ c.onHistoryRecord(ctx, paneID, uri)
1010 }1126 }
1011 }1127 }
1012 1128
1013 // updatePaneURI updates the pane's URI in the domain model.1129 // updatePaneURI updates the pane's URI in the domain model.
1014 // This is called on navigation so that session snapshots capture the current URL.1130 // This is called on navigation so that session snapshots capture the current URL.
1015-func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {1131+func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, uri string) {
1016 if c.onPaneURIUpdated != nil {1132 if c.onPaneURIUpdated != nil {
1017- c.onPaneURIUpdated(paneID, url)1133+ c.onPaneURIUpdated(paneID, uri)
1018 }1134 }
1019 }1135 }
1020 1136
@@ -1090,7 +1206,7 @@ func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
1090 c.revealMu.Unlock()1206 c.revealMu.Unlock()
1091 }1207 }
1092 1208
1093-func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {1209+func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
1094 c.revealMu.Lock()1210 c.revealMu.Lock()
1095 pending := c.pendingReveal[paneID]1211 pending := c.pendingReveal[paneID]
1096 if pending {1212 if pending {
@@ -1102,7 +1218,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.
1102 return1218 return
1103 }1219 }
1104 1220
1105- wv := c.webViews[paneID]1221+ wv := c.getWebViewLocked(paneID)
1106 if wv == nil || wv.IsDestroyed() {1222 if wv == nil || wv.IsDestroyed() {
1107 return1223 return
1108 }1224 }
@@ -1112,7 +1228,7 @@ func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.
1112 logging.FromContext(ctx).1228 logging.FromContext(ctx).
1113 Debug().1229 Debug().
1114 Str("pane_id", string(paneID)).1230 Str("pane_id", string(paneID)).
1115- Str("url", url).1231+ Str("uri", uri).
1116 Str("reason", reason).1232 Str("reason", reason).
1117 Msg("webview revealed")1233 Msg("webview revealed")
1118 }1234 }
@@ -1294,7 +1410,7 @@ func (c *ContentCoordinator) handlePopupCreate(
1294 }1410 }
1295 1411
1296 // Register WebView in our map (after successful insertion)1412 // Register WebView in our map (after successful insertion)
1297- c.webViews[paneID] = popupWV1413+ c.setWebViewLocked(paneID, popupWV)
1298 1414
1299 // Setup standard callbacks (after successful insertion to avoid leak)1415 // Setup standard callbacks (after successful insertion to avoid leak)
1300 c.setupWebViewCallbacks(ctx, paneID, popupWV)1416 c.setupWebViewCallbacks(ctx, paneID, popupWV)
@@ -1302,7 +1418,8 @@ func (c *ContentCoordinator) handlePopupCreate(
1302 // Setup OAuth auto-close if configured1418 // Setup OAuth auto-close if configured
1303 if hasConfig && oauthEnabled && isOAuth {1419 if hasConfig && oauthEnabled && isOAuth {
1304 popupPane.AutoClose = true1420 popupPane.AutoClose = true
1305- c.setupOAuthAutoClose(ctx, paneID, popupWV)1421+ c.trackOAuthPopup(popupID, parentPaneID)
1422+ c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
1306 log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")1423 log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
1307 }1424 }
1308 1425
@@ -1326,9 +1443,9 @@ func (c *ContentCoordinator) handlePopupCreate(
1326 popupWV.OnReadyToShow = func() {1443 popupWV.OnReadyToShow = func() {
1327 c.handlePopupReadyToShow(ctx, popupID)1444 c.handlePopupReadyToShow(ctx, popupID)
1328 }1445 }
1329- popupWV.OnClose = func() {1446+ popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
1330 c.handlePopupClose(ctx, popupID)1447 c.handlePopupClose(ctx, popupID)
1331- }1448+ })
1332 1449
1333 log.Info().1450 log.Info().
1334 Uint64("popup_id", uint64(popupID)).1451 Uint64("popup_id", uint64(popupID)).
@@ -1389,6 +1506,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
1389 c.popupMu.Unlock()1506 c.popupMu.Unlock()
1390 1507
1391 if wasPending && pending != nil {1508 if wasPending && pending != nil {
1509+ c.handlePopupOAuthClose(ctx, popupID)
1392 // Was never shown, just destroy1510 // Was never shown, just destroy
1393 pending.WebView.Destroy()1511 pending.WebView.Destroy()
1394 log.Debug().Msg("destroyed pending popup that was never shown")1512 log.Debug().Msg("destroyed pending popup that was never shown")
@@ -1405,6 +1523,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
1405 }1523 }
1406 1524
1407 if paneID == "" {1525 if paneID == "" {
1526+ c.handlePopupOAuthClose(ctx, popupID)
1408 log.Warn().Msg("popup close: could not find pane for webview")1527 log.Warn().Msg("popup close: could not find pane for webview")
1409 return1528 return
1410 }1529 }
@@ -1416,6 +1535,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
1416 }1535 }
1417 }1536 }
1418 1537
1538+ c.handlePopupOAuthClose(ctx, popupID)
1539+
1419 // Release the WebView (this will clean up tracking)1540 // Release the WebView (this will clean up tracking)
1420 c.ReleaseWebView(ctx, paneID)1541 c.ReleaseWebView(ctx, paneID)
1421 1542
@@ -1424,6 +1545,8 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
1424 1545
1425 // setupWebViewCallbacks configures standard callbacks and popup handling.1546 // setupWebViewCallbacks configures standard callbacks and popup handling.
1426 func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {1547 func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1548+ log := logging.FromContext(ctx)
1549+
1427 // Title changes1550 // Title changes
1428 wv.OnTitleChanged = func(title string) {1551 wv.OnTitleChanged = func(title string) {
1429 c.onTitleChanged(ctx, paneID, title)1552 c.onTitleChanged(ctx, paneID, title)
@@ -1453,7 +1576,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1453 1576
1454 // SPA navigation and external scheme handling1577 // SPA navigation and external scheme handling
1455 wv.OnURIChanged = func(uri string) {1578 wv.OnURIChanged = func(uri string) {
1456- log := logging.FromContext(ctx)
1457 if uri == "" {1579 if uri == "" {
1458 return1580 return
1459 }1581 }
@@ -1461,12 +1583,6 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1461 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)1583 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1462 // These are typically triggered by JavaScript redirects (window.location)1584 // These are typically triggered by JavaScript redirects (window.location)
1463 isExternal := urlutil.IsExternalScheme(uri)1585 isExternal := urlutil.IsExternalScheme(uri)
1464- log.Debug().
1465- Str("pane_id", string(paneID)).
1466- Str("uri", uri).
1467- Bool("is_external", isExternal).
1468- Bool("is_loading", wv.IsLoading()).
1469- Msg("OnURIChanged")
1470 1586
1471 if isExternal {1587 if isExternal {
1472 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")1588 log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
@@ -1500,6 +1616,36 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1500 c.onLinkHover(paneID, uri)1616 c.onLinkHover(paneID, uri)
1501 }1617 }
1502 1618
1619+ wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) {
1620+ originalURI := extractOriginalURIFromCrashPage(uri)
1621+ if !shouldRenderCrashPage(reason) {
1622+ log.Info().
1623+ Str("pane_id", string(paneID)).
1624+ Str("reason", reasonLabel).
1625+ Str("uri", uri).
1626+ Msg("web process termination handled without crash page")
1627+ return
1628+ }
1629+
1630+ crashURI := buildCrashPageURI(originalURI)
1631+ log.Warn().
1632+ Str("pane_id", string(paneID)).
1633+ Str("reason", reasonLabel).
1634+ Str("uri", uri).
1635+ Str("crash_uri", crashURI).
1636+ Msg("web process terminated, redirecting to crash page")
1637+
1638+ if err := wv.LoadURI(ctx, crashURI); err != nil {
1639+ log.Error().
1640+ Err(err).
1641+ Str("pane_id", string(paneID)).
1642+ Str("reason", reasonLabel).
1643+ Str("uri", uri).
1644+ Str("crash_uri", crashURI).
1645+ Msg("failed to load crash page after web process termination")
1646+ }
1647+ }
1648+
1503 // Fullscreen handlers for idle inhibition1649 // Fullscreen handlers for idle inhibition
1504 c.setupIdleInhibitionHandlers(ctx, paneID, wv)1650 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1505 1651
@@ -1512,12 +1658,19 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1512 // For providers using postMessage (like Google Sign-In), we rely on the provider calling1658 // For providers using postMessage (like Google Sign-In), we rely on the provider calling
1513 // window.close() which triggers WebKit's close signal.1659 // window.close() which triggers WebKit's close signal.
1514 // A long safety timeout (30s) catches popups that get stuck.1660 // A long safety timeout (30s) catches popups that get stuck.
1515-func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {1661+func (c *ContentCoordinator) setupOAuthAutoClose(
1662+ ctx context.Context,
1663+ paneID entity.PaneID,
1664+ popupID port.WebViewID,
1665+ wv *webkit.WebView,
1666+) {
1516 log := logging.FromContext(ctx)1667 log := logging.FromContext(ctx)
1517 1668
1518 // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)1669 // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
1519 var safetyTimer *time.Timer1670 var safetyTimer *time.Timer
1520 var safetyTimerMu sync.Mutex1671 var safetyTimerMu sync.Mutex
1672+ var cancelSafetyTimerOnce sync.Once
1673+ var requestCloseOnce sync.Once
1521 const oauthSafetyTimeout = 30 * time.Second1674 const oauthSafetyTimeout = 30 * time.Second
1522 1675
1523 startSafetyTimer := func() {1676 startSafetyTimer := func() {
@@ -1535,69 +1688,166 @@ func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID ent
1535 }1688 }
1536 1689
1537 cancelSafetyTimer := func() {1690 cancelSafetyTimer := func() {
1538- safetyTimerMu.Lock()1691+ cancelSafetyTimerOnce.Do(func() {
1539- defer safetyTimerMu.Unlock()1692+ safetyTimerMu.Lock()
1540- if safetyTimer != nil {1693+ defer safetyTimerMu.Unlock()
1541- safetyTimer.Stop()1694+ if safetyTimer != nil {
1542- safetyTimer = nil1695+ safetyTimer.Stop()
1543- }1696+ safetyTimer = nil
1697+ }
1698+ })
1544 }1699 }
1545 1700
1546- // Start safety timer immediately1701+ requestOAuthClose := func(uri string, reason string) {
1547- startSafetyTimer()1702+ c.capturePopupOAuthState(popupID, uri)
1548-1703+ cancelSafetyTimer()
1549- // Wrap the existing OnURIChanged to also check for OAuth callbacks1704+ log.Info().
1550- originalURIChanged := wv.OnURIChanged1705+ Str("pane", string(paneID)).
1551- wv.OnURIChanged = func(uri string) {1706+ Str("reason", reason).
1552- // Call original handler first1707+ Msg("oauth callback detected, closing")
1553- if originalURIChanged != nil {1708+ requestCloseOnce.Do(func() {
1554- originalURIChanged(uri)
1555- }
1556-
1557- // Check for OAuth callback (URL-based detection)
1558- if ShouldAutoClose(uri) {
1559- cancelSafetyTimer()
1560- log.Info().Str("pane", string(paneID)).Msg("oauth callback detected, closing")
1561 go func() {1709 go func() {
1562 time.Sleep(500 * time.Millisecond)1710 time.Sleep(500 * time.Millisecond)
1563 if wv != nil && !wv.IsDestroyed() {1711 if wv != nil && !wv.IsDestroyed() {
1564 wv.Close()1712 wv.Close()
1565 }1713 }
1566 }()1714 }()
1567- }1715+ })
1568 }1716 }
1569 1717
1570- // Monitor load events for URL-based detection1718+ // Start safety timer immediately.
1571- originalLoadChanged := wv.OnLoadChanged1719+ startSafetyTimer()
1572- wv.OnLoadChanged = func(event webkit.LoadEvent) {1720+
1573- if originalLoadChanged != nil {1721+ // Wrap OnURIChanged to check for OAuth callbacks.
1574- originalLoadChanged(event)1722+ wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) {
1723+ if ShouldAutoClose(uri) {
1724+ requestOAuthClose(uri, "uri_changed")
1575 }1725 }
1726+ })
1576 1727
1577- // Check URL on commit for immediate detection1728+ // Monitor load events for URL-based detection.
1729+ wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) {
1578 if event == webkit.LoadCommitted {1730 if event == webkit.LoadCommitted {
1579 uri := wv.URI()1731 uri := wv.URI()
1580 if ShouldAutoClose(uri) {1732 if ShouldAutoClose(uri) {
1581- cancelSafetyTimer()1733+ requestOAuthClose(uri, "load_committed")
1582- log.Info().Str("pane", string(paneID)).Msg("oauth callback on load, closing")
1583- go func() {
1584- time.Sleep(500 * time.Millisecond)
1585- if wv != nil && !wv.IsDestroyed() {
1586- wv.Close()
1587- }
1588- }()
1589 }1734 }
1590 }1735 }
1591- }1736+ })
1592 1737
1593- // Wrap OnClose to cancel safety timer when popup closes normally1738+ // Cancel safety timer on any close path.
1594- originalOnClose := wv.OnClose1739+ wv.OnClose = composeOnClose(func() {
1595- wv.OnClose = func() {
1596 cancelSafetyTimer()1740 cancelSafetyTimer()
1597- if originalOnClose != nil {1741+ }, wv.OnClose)
1598- originalOnClose()1742+}
1599- }1743+
1744+func (c *ContentCoordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
1745+ c.popupMu.Lock()
1746+ defer c.popupMu.Unlock()
1747+ if c.popupOAuth == nil {
1748+ c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
1749+ }
1750+ c.popupOAuth[popupID] = &popupOAuthState{
1751+ ParentPaneID: parentPaneID,
1752+ }
1753+}
1754+
1755+func (c *ContentCoordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
1756+ c.popupMu.Lock()
1757+ defer c.popupMu.Unlock()
1758+
1759+ state, ok := c.popupOAuth[popupID]
1760+ if !ok {
1761+ return
1762+ }
1763+
1764+ state.Seen = true
1765+ state.CallbackURI = uri
1766+ state.Success = IsOAuthSuccess(uri)
1767+ state.Error = IsOAuthError(uri)
1768+}
1769+
1770+func (c *ContentCoordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
1771+ log := logging.FromContext(ctx)
1772+
1773+ c.popupMu.Lock()
1774+ state, ok := c.popupOAuth[popupID]
1775+ if ok {
1776+ delete(c.popupOAuth, popupID)
1777+ }
1778+ c.popupMu.Unlock()
1779+
1780+ if !ok || state == nil || !state.Seen {
1781+ return
1782+ }
1783+
1784+ log.Debug().
1785+ Uint64("popup_id", uint64(popupID)).
1786+ Str("parent_pane_id", string(state.ParentPaneID)).
1787+ Bool("oauth_success", state.Success).
1788+ Bool("oauth_error", state.Error).
1789+ Msg("popup oauth result captured on close")
1790+
1791+ if !state.Success || state.ParentPaneID == "" {
1792+ return
1793+ }
1794+
1795+ c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
1796+}
1797+
1798+func (c *ContentCoordinator) scheduleParentPaneRefresh(
1799+ ctx context.Context,
1800+ parentPaneID entity.PaneID,
1801+ popupID port.WebViewID,
1802+) {
1803+ c.popupMu.Lock()
1804+ if c.popupRefresh == nil {
1805+ c.popupRefresh = make(map[entity.PaneID]*time.Timer)
1806+ }
1807+ if existing := c.popupRefresh[parentPaneID]; existing != nil {
1808+ existing.Stop()
1809+ }
1810+ c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
1811+ c.popupMu.Lock()
1812+ delete(c.popupRefresh, parentPaneID)
1813+ c.popupMu.Unlock()
1814+ cb := glib.SourceFunc(func(_ uintptr) bool {
1815+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
1816+ return false
1817+ })
1818+ glib.IdleAdd(&cb, 0)
1819+ })
1820+ c.popupMu.Unlock()
1821+}
1822+
1823+func (c *ContentCoordinator) refreshPaneAfterOAuth(
1824+ ctx context.Context,
1825+ parentPaneID entity.PaneID,
1826+ popupID port.WebViewID,
1827+) {
1828+ log := logging.FromContext(ctx)
1829+ wv := c.getWebViewLocked(parentPaneID)
1830+ if wv == nil || wv.IsDestroyed() {
1831+ log.Debug().
1832+ Str("parent_pane_id", string(parentPaneID)).
1833+ Uint64("popup_id", uint64(popupID)).
1834+ Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
1835+ return
1836+ }
1837+
1838+ if err := wv.Reload(ctx); err != nil {
1839+ log.Warn().
1840+ Err(err).
1841+ Str("parent_pane_id", string(parentPaneID)).
1842+ Uint64("popup_id", uint64(popupID)).
1843+ Msg("failed parent pane refresh after oauth popup close")
1844+ return
1600 }1845 }
1846+
1847+ log.Info().
1848+ Str("parent_pane_id", string(parentPaneID)).
1849+ Uint64("popup_id", uint64(popupID)).
1850+ Msg("refreshed parent pane after oauth popup success")
1601 }1851 }
1602 1852
1603 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.1853 // handleLinkMiddleClick handles middle-click / Ctrl+click on links.
@@ -1643,7 +1893,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1643 newPane.URI = uri1893 newPane.URI = uri
1644 1894
1645 // Register WebView1895 // Register WebView
1646- c.webViews[paneID] = newWV1896+ c.setWebViewLocked(paneID, newWV)
1647 1897
1648 // Setup callbacks1898 // Setup callbacks
1649 c.setupWebViewCallbacks(ctx, paneID, newWV)1899 c.setupWebViewCallbacks(ctx, paneID, newWV)
A internal/ui/coordinator/content_termination_test.go +27 -0
@@ -0,0 +1,27 @@
1+package coordinator
2+
3+import (
4+ "testing"
5+
6+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestShouldRenderCrashPage(t *testing.T) {
11+ assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessCrashedValue))
12+ assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessExceededMemoryLimitValue))
13+ assert.False(t, shouldRenderCrashPage(webkitlib.WebProcessTerminatedByApiValue))
14+ assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessTerminationReason(99)))
15+}
16+
17+func TestExtractOriginalURIFromCrashPage(t *testing.T) {
18+ assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19+ assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash"))
20+ assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))
21+ assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))
22+}
23+
24+func TestBuildCrashPageURI(t *testing.T) {
25+ assert.Equal(t, "dumb://home/crash", buildCrashPageURI(""))
26+ assert.Equal(t, "dumb://home/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))
27+}
M internal/ui/coordinator/navigation.go +9 -1
@@ -233,7 +233,15 @@ func (c *NavigationCoordinator) RecordHistory(ctx context.Context, paneID entity
233 return233 return
234 }234 }
235 235
236- c.navigateUC.RecordHistory(ctx, url)236+ c.navigateUC.RecordHistory(ctx, string(paneID), url)
237+}
238+
239+// ClearPaneHistory clears per-pane navigation history deduplication state.
240+func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
241+ if c.navigateUC == nil {
242+ return
243+ }
244+ c.navigateUC.ClearPaneHistory(string(paneID))
237 }245 }
238 246
239 // ActiveWebView returns the WebView for the active pane (for zoom operations).247 // ActiveWebView returns the WebView for the active pane (for zoom operations).
M internal/ui/coordinator/oauth.go +44 -0
@@ -2,6 +2,8 @@ package coordinator
2 2
3 import (3 import (
4 "strings"4 "strings"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/webkit"
5 )7 )
6 8
7 // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.9 // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
@@ -138,3 +140,45 @@ func IsOAuthError(url string) bool {
138 lower := strings.ToLower(url)140 lower := strings.ToLower(url)
139 return strings.Contains(lower, "error=")141 return strings.Contains(lower, "error=")
140 }142 }
143+
144+func composeOnClose(existing, next func()) func() {
145+ if existing == nil {
146+ return next
147+ }
148+ if next == nil {
149+ return existing
150+ }
151+ return func() {
152+ existing()
153+ next()
154+ }
155+}
156+
157+func composeOnURIChanged(existing, next func(string)) func(string) {
158+ if existing == nil {
159+ return next
160+ }
161+ if next == nil {
162+ return existing
163+ }
164+ return func(uri string) {
165+ existing(uri)
166+ next(uri)
167+ }
168+}
169+
170+func composeOnLoadChanged(
171+ existing func(webkit.LoadEvent),
172+ next func(webkit.LoadEvent),
173+) func(webkit.LoadEvent) {
174+ if existing == nil {
175+ return next
176+ }
177+ if next == nil {
178+ return existing
179+ }
180+ return func(event webkit.LoadEvent) {
181+ existing(event)
182+ next(event)
183+ }
184+}
A internal/ui/coordinator/oauth_test.go +110 -0
@@ -0,0 +1,110 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/webkit"
13+)
14+
15+func TestComposeOnClose_Order(t *testing.T) {
16+ var calls []string
17+
18+ composed := composeOnClose(
19+ func() { calls = append(calls, "existing") },
20+ func() { calls = append(calls, "next") },
21+ )
22+ composed()
23+
24+ assert.Equal(t, []string{"existing", "next"}, calls)
25+}
26+
27+func TestComposeOnURIChanged_Order(t *testing.T) {
28+ var calls []string
29+
30+ composed := composeOnURIChanged(
31+ func(_ string) { calls = append(calls, "existing") },
32+ func(_ string) { calls = append(calls, "next") },
33+ )
34+ composed("https://example.com")
35+
36+ assert.Equal(t, []string{"existing", "next"}, calls)
37+}
38+
39+func TestComposeOnLoadChanged_Order(t *testing.T) {
40+ var calls []string
41+
42+ composed := composeOnLoadChanged(
43+ func(_ webkit.LoadEvent) { calls = append(calls, "existing") },
44+ func(_ webkit.LoadEvent) { calls = append(calls, "next") },
45+ )
46+ composed(webkit.LoadCommitted)
47+
48+ assert.Equal(t, []string{"existing", "next"}, calls)
49+}
50+
51+func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) {
52+ parentPaneID := entity.PaneID("parent-pane")
53+ popupID := port.WebViewID(101)
54+
55+ c := &ContentCoordinator{
56+ webViews: make(map[entity.PaneID]*webkit.WebView),
57+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
58+ popupRefresh: make(map[entity.PaneID]*time.Timer),
59+ }
60+
61+ c.trackOAuthPopup(popupID, parentPaneID)
62+ c.capturePopupOAuthState(popupID, "https://app/callback?code=123")
63+ c.handlePopupOAuthClose(context.Background(), popupID)
64+
65+ c.popupMu.RLock()
66+ _, exists := c.popupOAuth[popupID]
67+ refreshTimer := c.popupRefresh[parentPaneID]
68+ c.popupMu.RUnlock()
69+
70+ assert.False(t, exists, "oauth state should be removed after close handling")
71+ assert.NotNil(t, refreshTimer, "successful oauth close should schedule parent refresh")
72+
73+ waitFor(t, time.Second, func() bool {
74+ c.popupMu.RLock()
75+ defer c.popupMu.RUnlock()
76+ return c.popupRefresh[parentPaneID] == nil
77+ })
78+}
79+
80+func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
81+ parentPaneID := entity.PaneID("parent-pane")
82+ popupID := port.WebViewID(102)
83+
84+ c := &ContentCoordinator{
85+ webViews: make(map[entity.PaneID]*webkit.WebView),
86+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
87+ popupRefresh: make(map[entity.PaneID]*time.Timer),
88+ }
89+
90+ c.trackOAuthPopup(popupID, parentPaneID)
91+ c.capturePopupOAuthState(popupID, "https://app/callback?error=access_denied")
92+ c.handlePopupOAuthClose(context.Background(), popupID)
93+
94+ c.popupMu.RLock()
95+ defer c.popupMu.RUnlock()
96+ assert.Nil(t, c.popupRefresh[parentPaneID], "oauth errors should not refresh parent pane")
97+}
98+
99+func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
100+ t.Helper()
101+
102+ deadline := time.Now().Add(timeout)
103+ for time.Now().Before(deadline) {
104+ if condition() {
105+ return
106+ }
107+ time.Sleep(20 * time.Millisecond)
108+ }
109+ t.Fatalf("condition not met before timeout (%s)", timeout)
110+}
M internal/ui/coordinator/workspace.go +180 -104
@@ -35,6 +35,7 @@ type WorkspaceCoordinator struct {
35 onCloseLastPane func(ctx context.Context) error35 onCloseLastPane func(ctx context.Context) error
36 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior36 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
37 onStateChanged func() // For session snapshots37 onStateChanged func() // For session snapshots
38+ onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
38 }39 }
39 40
40 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.41 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -64,6 +65,16 @@ type stackPaneContext struct {
64 originalTitle string65 originalTitle string
65 }66 }
66 67
68+type incrementalCloseContext struct {
69+ parentNode *entity.PaneNode
70+ siblingNode *entity.PaneNode
71+ grandparentNode *entity.PaneNode
72+ parentWidget layout.Widget
73+ siblingIsStartChild bool
74+ parentIsStartInGrand bool
75+ precheckReason string
76+}
77+
67 // NewWorkspaceCoordinator creates a new WorkspaceCoordinator.78 // NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
68 func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {79 func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
69 log := logging.FromContext(ctx)80 log := logging.FromContext(ctx)
@@ -90,6 +101,11 @@ func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
90 c.onStateChanged = fn101 c.onStateChanged = fn
91 }102 }
92 103
104+// SetOnPaneClosed sets a callback invoked after a pane is successfully closed.
105+func (c *WorkspaceCoordinator) SetOnPaneClosed(fn func(paneID entity.PaneID)) {
106+ c.onPaneClosed = fn
107+}
108+
93 // notifyStateChanged triggers the state changed callback if set.109 // notifyStateChanged triggers the state changed callback if set.
94 func (c *WorkspaceCoordinator) notifyStateChanged() {110 func (c *WorkspaceCoordinator) notifyStateChanged() {
95 if c.onStateChanged != nil {111 if c.onStateChanged != nil {
@@ -717,52 +733,8 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
717 return nil733 return nil
718 }734 }
719 735
720- // BEFORE domain changes: capture widget info for incremental close736+ // BEFORE domain changes: capture incremental close context.
721- var parentNode *entity.PaneNode737+ closeCtx := c.captureIncrementalCloseContext(wsView, activePane)
722- var siblingNode *entity.PaneNode
723- var grandparentNode *entity.PaneNode
724- var parentWidget layout.Widget
725- var siblingIsStartChild bool // true if sibling is the start (left/top) child in parent
726- var parentIsStartInGrand bool // true if parent is the start (left/top) child in grandparent
727-
728- if wsView != nil {
729- tr := wsView.TreeRenderer()
730- if tr != nil && activePane.Parent != nil {
731- parentNode = activePane.Parent
732-
733- // Find sibling (the other child of parent)
734- // If closing pane is start child, sibling is end child (and vice versa)
735- if parentNode.Left() == activePane {
736- siblingNode = parentNode.Right()
737- siblingIsStartChild = false // sibling is end/right child
738- } else {
739- siblingNode = parentNode.Left()
740- siblingIsStartChild = true // sibling is start/left child
741- }
742-
743- grandparentNode = parentNode.Parent
744- parentWidget = tr.Lookup(parentNode.ID)
745-
746- // Determine parent's position in grandparent (if grandparent exists)
747- if grandparentNode != nil {
748- parentIsStartInGrand = grandparentNode.Left() == parentNode
749- }
750-
751- log.Debug().
752- Str("parent_id", parentNode.ID).
753- Str("sibling_id", func() string {
754- if siblingNode != nil {
755- return siblingNode.ID
756- }
757- return nilString
758- }()).
759- Bool("sibling_is_start", siblingIsStartChild).
760- Bool("parent_is_start_in_grand", parentIsStartInGrand).
761- Bool("has_grandparent", grandparentNode != nil).
762- Bool("has_parent_widget", parentWidget != nil).
763- Msg("captured close context")
764- }
765- }
766 738
767 // Now do domain changes739 // Now do domain changes
768 _, err := c.panesUC.Close(ctx, ws, activePane)740 _, err := c.panesUC.Close(ctx, ws, activePane)
@@ -776,13 +748,11 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
776 wsView,748 wsView,
777 ws,749 ws,
778 closingPaneID,750 closingPaneID,
779- parentNode,751+ closeCtx,
780- siblingNode,
781- grandparentNode,
782- parentWidget,
783- siblingIsStartChild,
784- parentIsStartInGrand,
785 )752 )
753+ if c.onPaneClosed != nil {
754+ c.onPaneClosed(closingPaneID)
755+ }
786 756
787 // Notify state change for session snapshots757 // Notify state change for session snapshots
788 c.notifyStateChanged()758 c.notifyStateChanged()
@@ -824,35 +794,8 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
824 return nil794 return nil
825 }795 }
826 796
827- // BEFORE domain changes: capture widget info for incremental close797+ // BEFORE domain changes: capture incremental close context.
828- var parentNode *entity.PaneNode798+ closeCtx := c.captureIncrementalCloseContext(wsView, paneNode)
829- var siblingNode *entity.PaneNode
830- var grandparentNode *entity.PaneNode
831- var parentWidget layout.Widget
832- var siblingIsStartChild bool
833- var parentIsStartInGrand bool
834-
835- if wsView != nil {
836- tr := wsView.TreeRenderer()
837- if tr != nil && paneNode.Parent != nil {
838- parentNode = paneNode.Parent
839-
840- if parentNode.Left() == paneNode {
841- siblingNode = parentNode.Right()
842- siblingIsStartChild = false
843- } else {
844- siblingNode = parentNode.Left()
845- siblingIsStartChild = true
846- }
847-
848- grandparentNode = parentNode.Parent
849- parentWidget = tr.Lookup(parentNode.ID)
850-
851- if grandparentNode != nil {
852- parentIsStartInGrand = grandparentNode.Left() == parentNode
853- }
854- }
855- }
856 799
857 // Now do domain changes800 // Now do domain changes
858 _, err := c.panesUC.Close(ctx, ws, paneNode)801 _, err := c.panesUC.Close(ctx, ws, paneNode)
@@ -866,13 +809,11 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
866 wsView,809 wsView,
867 ws,810 ws,
868 paneID,811 paneID,
869- parentNode,812+ closeCtx,
870- siblingNode,
871- grandparentNode,
872- parentWidget,
873- siblingIsStartChild,
874- parentIsStartInGrand,
875 )813 )
814+ if c.onPaneClosed != nil {
815+ c.onPaneClosed(paneID)
816+ }
876 817
877 // Notify state change for session snapshots818 // Notify state change for session snapshots
878 c.notifyStateChanged()819 c.notifyStateChanged()
@@ -894,6 +835,16 @@ func (c *WorkspaceCoordinator) doIncrementalClose(
894 parentIsStartInGrand bool, // true if parent is start/left child in grandparent835 parentIsStartInGrand bool, // true if parent is start/left child in grandparent
895 ) error {836 ) error {
896 log := logging.FromContext(ctx)837 log := logging.FromContext(ctx)
838+ if parentNode == nil {
839+ return fmt.Errorf("parent node missing")
840+ }
841+ if siblingNode == nil {
842+ return fmt.Errorf("sibling node missing")
843+ }
844+ if parentWidget == nil {
845+ return fmt.Errorf("parent widget missing")
846+ }
847+
897 tr := wsView.TreeRenderer()848 tr := wsView.TreeRenderer()
898 if tr == nil {849 if tr == nil {
899 return fmt.Errorf("tree renderer not available")850 return fmt.Errorf("tree renderer not available")
@@ -1015,12 +966,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1015 wsView *component.WorkspaceView,966 wsView *component.WorkspaceView,
1016 ws *entity.Workspace,967 ws *entity.Workspace,
1017 paneID entity.PaneID,968 paneID entity.PaneID,
1018- parentNode *entity.PaneNode,969+ closeCtx incrementalCloseContext,
1019- siblingNode *entity.PaneNode,
1020- grandparentNode *entity.PaneNode,
1021- parentWidget layout.Widget,
1022- siblingIsStartChild bool,
1023- parentIsStartInGrand bool,
1024 ) {970 ) {
1025 if wsView == nil {971 if wsView == nil {
1026 return972 return
@@ -1028,29 +974,40 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1028 974
1029 log := logging.FromContext(ctx)975 log := logging.FromContext(ctx)
1030 976
1031- if parentNode != nil {977+ if closeCtx.parentNode != nil {
1032 var closeErr error978 var closeErr error
1033 979
1034- if parentNode.IsStacked {980+ if closeCtx.parentNode.IsStacked {
1035- closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode)981+ closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, closeCtx.parentNode)
1036- } else if siblingNode != nil && parentWidget != nil {982+ } else if closeCtx.precheckReason != "" {
983+ closeErr = fmt.Errorf("incremental close precheck failed: %s", closeCtx.precheckReason)
984+ } else if closeCtx.siblingNode != nil && closeCtx.parentWidget != nil {
1037 closeErr = c.doIncrementalClose(985 closeErr = c.doIncrementalClose(
1038 ctx,986 ctx,
1039 wsView,987 wsView,
1040 paneID,988 paneID,
1041- siblingNode,989+ closeCtx.siblingNode,
1042- parentNode,990+ closeCtx.parentNode,
1043- grandparentNode,991+ closeCtx.grandparentNode,
1044- parentWidget,992+ closeCtx.parentWidget,
1045- siblingIsStartChild,993+ closeCtx.siblingIsStartChild,
1046- parentIsStartInGrand,994+ closeCtx.parentIsStartInGrand,
1047 )995 )
1048 } else {996 } else {
1049 closeErr = fmt.Errorf("missing context for incremental close")997 closeErr = fmt.Errorf("missing context for incremental close")
1050 }998 }
1051 999
1052 if closeErr != nil {1000 if closeErr != nil {
1053- log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild")1001+ fallbackLog := log.Warn().Err(closeErr).
1002+ Str("pane_id", string(paneID)).
1003+ Str("parent_id", paneNodeID(closeCtx.parentNode)).
1004+ Str("sibling_id", paneNodeID(closeCtx.siblingNode)).
1005+ Str("grandparent_id", paneNodeID(closeCtx.grandparentNode)).
1006+ Bool("has_parent_widget", closeCtx.parentWidget != nil)
1007+ if closeCtx.precheckReason != "" {
1008+ fallbackLog = fallbackLog.Str("precheck_reason", closeCtx.precheckReason)
1009+ }
1010+ fallbackLog.Msg("incremental close failed, falling back to rebuild")
1054 if err := wsView.Rebuild(ctx); err != nil {1011 if err := wsView.Rebuild(ctx); err != nil {
1055 log.Error().Err(err).Msg("failed to rebuild workspace view")1012 log.Error().Err(err).Msg("failed to rebuild workspace view")
1056 }1013 }
@@ -1073,6 +1030,113 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1073 wsView.FocusPane(ws.ActivePaneID)1030 wsView.FocusPane(ws.ActivePaneID)
1074 }1031 }
1075 1032
1033+func (c *WorkspaceCoordinator) captureIncrementalCloseContext(
1034+ wsView *component.WorkspaceView,
1035+ closingPane *entity.PaneNode,
1036+) incrementalCloseContext {
1037+ closeCtx, err := deriveIncrementalCloseTreeContext(closingPane)
1038+ if err != nil {
1039+ closeCtx.precheckReason = err.Error()
1040+ return closeCtx
1041+ }
1042+
1043+ if closeCtx.parentNode == nil || closeCtx.parentNode.IsStacked {
1044+ return closeCtx
1045+ }
1046+
1047+ if wsView == nil {
1048+ closeCtx.precheckReason = "workspace view unavailable"
1049+ return closeCtx
1050+ }
1051+
1052+ tr := wsView.TreeRenderer()
1053+ if tr == nil {
1054+ closeCtx.precheckReason = "tree renderer unavailable"
1055+ return closeCtx
1056+ }
1057+
1058+ closeCtx.parentWidget = tr.Lookup(closeCtx.parentNode.ID)
1059+ if closeCtx.parentWidget == nil {
1060+ closeCtx.precheckReason = "parent widget missing"
1061+ }
1062+
1063+ return closeCtx
1064+}
1065+
1066+func deriveIncrementalCloseTreeContext(closingPane *entity.PaneNode) (incrementalCloseContext, error) {
1067+ var closeCtx incrementalCloseContext
1068+ if closingPane == nil {
1069+ return closeCtx, errors.New("closing pane missing")
1070+ }
1071+
1072+ parentNode := closingPane.Parent
1073+ if parentNode == nil {
1074+ return closeCtx, errors.New("closing pane has no parent")
1075+ }
1076+
1077+ closeCtx.parentNode = parentNode
1078+ closeCtx.grandparentNode = parentNode.Parent
1079+
1080+ if parentNode.IsStacked {
1081+ return closeCtx, nil
1082+ }
1083+
1084+ if !parentNode.IsSplit() {
1085+ return closeCtx, fmt.Errorf("parent node is not split: %s", parentNode.ID)
1086+ }
1087+
1088+ if len(parentNode.Children) != 2 {
1089+ return closeCtx, fmt.Errorf("split parent has invalid child count: %d", len(parentNode.Children))
1090+ }
1091+
1092+ leftChild := parentNode.Left()
1093+ rightChild := parentNode.Right()
1094+ if leftChild == closingPane && rightChild == nil {
1095+ return closeCtx, errors.New("sibling missing")
1096+ }
1097+ if rightChild == closingPane && leftChild == nil {
1098+ return closeCtx, errors.New("sibling missing")
1099+ }
1100+ if leftChild == nil || rightChild == nil {
1101+ return closeCtx, errors.New("split parent has nil child")
1102+ }
1103+
1104+ switch {
1105+ case leftChild == closingPane:
1106+ closeCtx.siblingNode = rightChild
1107+ closeCtx.siblingIsStartChild = false
1108+ case rightChild == closingPane:
1109+ closeCtx.siblingNode = leftChild
1110+ closeCtx.siblingIsStartChild = true
1111+ default:
1112+ return closeCtx, fmt.Errorf("closing pane not found under parent: %s", parentNode.ID)
1113+ }
1114+
1115+ if closeCtx.siblingNode == nil {
1116+ return closeCtx, errors.New("sibling missing")
1117+ }
1118+
1119+ if closeCtx.grandparentNode != nil {
1120+ switch {
1121+ case closeCtx.grandparentNode.Left() == parentNode:
1122+ closeCtx.parentIsStartInGrand = true
1123+ case closeCtx.grandparentNode.Right() == parentNode:
1124+ closeCtx.parentIsStartInGrand = false
1125+ default:
1126+ return closeCtx, fmt.Errorf("parent not found under grandparent: %s", closeCtx.grandparentNode.ID)
1127+ }
1128+ }
1129+
1130+ return closeCtx, nil
1131+}
1132+
1133+func paneNodeID(node *entity.PaneNode) string {
1134+ if node == nil {
1135+ return nilString
1136+ }
1137+ return node.ID
1138+}
1139+
1076 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.1140 // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
1077 func (c *WorkspaceCoordinator) doIncrementalStackClose(1141 func (c *WorkspaceCoordinator) doIncrementalStackClose(
1078 ctx context.Context,1142 ctx context.Context,
@@ -1559,6 +1623,18 @@ func (c *WorkspaceCoordinator) onTitleBarClick(ctx context.Context, stackNode *e
1559 }1623 }
1560 clickedPaneID := clickedChild.Pane.ID1624 clickedPaneID := clickedChild.Pane.ID
1561 1625
1626+ // Sync the incoming pane's title bar with its current WebView title
1627+ // to avoid showing a stale title after navigation occurred while hidden.
1628+ incomingTitle := c.contentCoord.GetTitle(clickedPaneID)
1629+ if incomingTitle == "" {
1630+ incomingTitle = clickedChild.Pane.Title
1631+ }
1632+ if incomingTitle != "" {
1633+ if err := sv.UpdateTitle(clickedIndex, incomingTitle); err != nil {
1634+ log.Warn().Err(err).Msg("failed to update incoming pane title")
1635+ }
1636+ }
1637+
1562 // Update StackedView active index1638 // Update StackedView active index
1563 if err := sv.SetActive(ctx, clickedIndex); err != nil {1639 if err := sv.SetActive(ctx, clickedIndex); err != nil {
1564 log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")1640 log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack")
A internal/ui/coordinator/workspace_close_test.go +138 -0
@@ -0,0 +1,138 @@
1+package coordinator
2+
3+import (
4+ "strings"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func testLeafNode(id string) *entity.PaneNode {
12+ return &entity.PaneNode{
13+ ID: id,
14+ Pane: entity.NewPane(entity.PaneID(id)),
15+ }
16+}
17+
18+func testSplitNode(id string, left, right *entity.PaneNode) *entity.PaneNode {
19+ node := &entity.PaneNode{
20+ ID: id,
21+ SplitDir: entity.SplitHorizontal,
22+ Children: []*entity.PaneNode{left, right},
23+ }
24+ if left != nil {
25+ left.Parent = node
26+ }
27+ if right != nil {
28+ right.Parent = node
29+ }
30+ return node
31+}
32+
33+func TestDeriveIncrementalCloseTreeContext_ValidSplit(t *testing.T) {
34+ closing := testLeafNode("closing")
35+ sibling := testLeafNode("sibling")
36+ parent := testSplitNode("parent", closing, sibling)
37+ other := testLeafNode("other")
38+ grand := testSplitNode("grand", parent, other)
39+
40+ ctx, err := deriveIncrementalCloseTreeContext(closing)
41+ if err != nil {
42+ t.Fatalf("unexpected error: %v", err)
43+ }
44+
45+ if ctx.parentNode != parent {
46+ t.Fatalf("parent mismatch")
47+ }
48+ if ctx.siblingNode != sibling {
49+ t.Fatalf("sibling mismatch")
50+ }
51+ if ctx.grandparentNode != grand {
52+ t.Fatalf("grandparent mismatch")
53+ }
54+ if ctx.siblingIsStartChild {
55+ t.Fatalf("sibling should be end child")
56+ }
57+ if !ctx.parentIsStartInGrand {
58+ t.Fatalf("parent should be start child in grandparent")
59+ }
60+}
61+
62+func TestDeriveIncrementalCloseTreeContext_MissingSibling(t *testing.T) {
63+ closing := testLeafNode("closing")
64+ parent := testSplitNode("parent", closing, nil)
65+
66+ _, err := deriveIncrementalCloseTreeContext(closing)
67+ if err == nil {
68+ t.Fatalf("expected invariant error")
69+ }
70+ if !strings.Contains(err.Error(), "sibling") && !strings.Contains(err.Error(), "nil child") {
71+ t.Fatalf("expected sibling/nil-child error, got: %v", err)
72+ }
73+
74+ if got := paneNodeID(parent); got != "parent" {
75+ t.Fatalf("unexpected parent id helper output: %s", got)
76+ }
77+}
78+
79+func TestDeriveIncrementalCloseTreeContext_ParentDoesNotContainClosingPane(t *testing.T) {
80+ closing := testLeafNode("closing")
81+ left := testLeafNode("left")
82+ right := testLeafNode("right")
83+ parent := testSplitNode("parent", left, right)
84+ closing.Parent = parent
85+
86+ _, err := deriveIncrementalCloseTreeContext(closing)
87+ if err == nil {
88+ t.Fatalf("expected invariant error")
89+ }
90+ if !strings.Contains(err.Error(), "not found under parent") {
91+ t.Fatalf("unexpected error: %v", err)
92+ }
93+}
94+
95+func TestCaptureIncrementalCloseContext_MissingSiblingSetsPrecheckReason(t *testing.T) {
96+ closing := testLeafNode("closing")
97+ testSplitNode("parent", closing, nil)
98+
99+ coord := &WorkspaceCoordinator{}
100+ ctx := coord.captureIncrementalCloseContext(nil, closing)
101+ if ctx.precheckReason == "" {
102+ t.Fatalf("expected precheck reason")
103+ }
104+}
105+
106+func TestDeriveIncrementalCloseTreeContext_ConcurrentPaneAndTabCloseSnapshots(t *testing.T) {
107+ paneCloseNode := testLeafNode("pane-close")
108+ paneCloseSibling := testLeafNode("pane-close-sibling")
109+ testSplitNode("pane-parent", paneCloseNode, paneCloseSibling)
110+
111+ tabCloseNode := testLeafNode("tab-close")
112+ tabCloseSibling := testLeafNode("tab-close-sibling")
113+ testSplitNode("tab-parent", tabCloseSibling, tabCloseNode)
114+
115+ run := func(node *entity.PaneNode, wg *sync.WaitGroup, errCh chan<- error) {
116+ defer wg.Done()
117+ for i := 0; i < 200; i++ {
118+ if _, err := deriveIncrementalCloseTreeContext(node); err != nil {
119+ errCh <- err
120+ return
121+ }
122+ }
123+ }
124+
125+ errCh := make(chan error, 2)
126+ var wg sync.WaitGroup
127+ wg.Add(2)
128+ go run(paneCloseNode, &wg, errCh)
129+ go run(tabCloseNode, &wg, errCh)
130+ wg.Wait()
131+ close(errCh)
132+
133+ for err := range errCh {
134+ if err != nil {
135+ t.Fatalf("unexpected concurrent derive error: %v", err)
136+ }
137+ }
138+}
M internal/ui/input/hover.go +25 -2
@@ -18,16 +18,21 @@ const (
18 // HoverCallback is called when a pane should receive focus from hover.18 // HoverCallback is called when a pane should receive focus from hover.
19 type HoverCallback func(paneID entity.PaneID)19 type HoverCallback func(paneID entity.PaneID)
20 20
21+// MotionCallback is called when intentional mouse movement is detected.
22+type MotionCallback func()
23+
21 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.24 // HoverHandler handles mouse hover events for focus-follows-mouse behavior.
22 // It uses a debounce timer to avoid rapid focus switches.25 // It uses a debounce timer to avoid rapid focus switches.
23 type HoverHandler struct {26 type HoverHandler struct {
24 motionCtrl *gtk.EventControllerMotion27 motionCtrl *gtk.EventControllerMotion
25 paneID entity.PaneID28 paneID entity.PaneID
26 onEnter HoverCallback29 onEnter HoverCallback
30+ onMotion MotionCallback
27 31
28 // Callback retention: must stay reachable by Go GC.32 // Callback retention: must stay reachable by Go GC.
29- enterCb func(gtk.EventControllerMotion, float64, float64)33+ enterCb func(gtk.EventControllerMotion, float64, float64)
30- leaveCb func(gtk.EventControllerMotion)34+ leaveCb func(gtk.EventControllerMotion)
35+ motionCb func(gtk.EventControllerMotion, float64, float64)
31 36
32 timer *time.Timer37 timer *time.Timer
33 timerMu sync.Mutex38 timerMu sync.Mutex
@@ -52,6 +57,13 @@ func (h *HoverHandler) SetOnEnter(fn HoverCallback) {
52 h.onEnter = fn57 h.onEnter = fn
53 }58 }
54 59
60+// SetOnMotion sets the callback for intentional mouse movement.
61+// This fires on actual mouse motion within the pane, not on synthetic
62+// enter/leave events from GTK widget rearrangement.
63+func (h *HoverHandler) SetOnMotion(fn MotionCallback) {
64+ h.onMotion = fn
65+}
66+
55 // AttachTo attaches the hover handler to a GTK widget.67 // AttachTo attaches the hover handler to a GTK widget.
56 func (h *HoverHandler) AttachTo(widget *gtk.Widget) {68 func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
57 log := logging.FromContext(h.ctx)69 log := logging.FromContext(h.ctx)
@@ -79,6 +91,16 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
79 }91 }
80 h.motionCtrl.ConnectLeave(&h.leaveCb)92 h.motionCtrl.ConnectLeave(&h.leaveCb)
81 93
94+ // Connect motion handler to detect intentional mouse movement.
95+ // Unlike enter/leave, motion only fires when the cursor physically moves
96+ // inside the widget — not on synthetic events from widget rearrangement.
97+ h.motionCb = func(_ gtk.EventControllerMotion, _ float64, _ float64) {
98+ if h.onMotion != nil {
99+ h.onMotion()
100+ }
101+ }
102+ h.motionCtrl.ConnectMotion(&h.motionCb)
103+
82 // Add controller to widget104 // Add controller to widget
83 widget.AddController(&h.motionCtrl.EventController)105 widget.AddController(&h.motionCtrl.EventController)
84 106
@@ -143,4 +165,5 @@ func (h *HoverHandler) Detach() {
143 h.motionCtrl = nil165 h.motionCtrl = nil
144 h.enterCb = nil166 h.enterCb = nil
145 h.leaveCb = nil167 h.leaveCb = nil
168+ h.motionCb = nil
146 }169 }
M internal/ui/layout/gtk_widgets.go +3 -3
@@ -159,14 +159,14 @@ func (p *gtkPaned) GetShrinkEndChild() bool { return p.inner.GetShrinkEndChil
159 func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) }159 func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) }
160 func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() }160 func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() }
161 161
162-func (p *gtkPaned) ConnectMap(callback func()) uint32 {162+func (p *gtkPaned) ConnectMap(callback func()) uint {
163 cb := func(_ gtk.Widget) {163 cb := func(_ gtk.Widget) {
164 callback()164 callback()
165 }165 }
166 return p.inner.ConnectMap(&cb)166 return p.inner.ConnectMap(&cb)
167 }167 }
168 168
169-func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {169+func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {
170 cb := func(_ gobject.Object, _ uintptr) {170 cb := func(_ gobject.Object, _ uintptr) {
171 callback()171 callback()
172 }172 }
@@ -539,7 +539,7 @@ func (btn *gtkButton) GetChild() Widget {
539 return &gtkWidget{inner: child}539 return &gtkWidget{inner: child}
540 }540 }
541 541
542-func (btn *gtkButton) ConnectClicked(callback func()) uint32 {542+func (btn *gtkButton) ConnectClicked(callback func()) uint {
543 cb := func(_ gtk.Button) {543 cb := func(_ gtk.Button) {
544 callback()544 callback()
545 }545 }
M internal/ui/layout/mocks/mocks.go +18 -18
@@ -1485,18 +1485,18 @@ func (_c *MockPanedWidget_ComputePoint_Call) RunAndReturn(run func(target layout
1485 }1485 }
1486 1486
1487 // ConnectMap provides a mock function for the type MockPanedWidget1487 // ConnectMap provides a mock function for the type MockPanedWidget
1488-func (_mock *MockPanedWidget) ConnectMap(callback func()) uint32 {1488+func (_mock *MockPanedWidget) ConnectMap(callback func()) uint {
1489 ret := _mock.Called(callback)1489 ret := _mock.Called(callback)
1490 1490
1491 if len(ret) == 0 {1491 if len(ret) == 0 {
1492 panic("no return value specified for ConnectMap")1492 panic("no return value specified for ConnectMap")
1493 }1493 }
1494 1494
1495- var r0 uint321495+ var r0 uint
1496- if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {1496+ if returnFunc, ok := ret.Get(0).(func(func()) uint); ok {
1497 r0 = returnFunc(callback)1497 r0 = returnFunc(callback)
1498 } else {1498 } else {
1499- r0 = ret.Get(0).(uint32)1499+ r0 = ret.Get(0).(uint)
1500 }1500 }
1501 return r01501 return r0
1502 }1502 }
@@ -1525,29 +1525,29 @@ func (_c *MockPanedWidget_ConnectMap_Call) Run(run func(callback func())) *MockP
1525 return _c1525 return _c
1526 }1526 }
1527 1527
1528-func (_c *MockPanedWidget_ConnectMap_Call) Return(v uint32) *MockPanedWidget_ConnectMap_Call {1528+func (_c *MockPanedWidget_ConnectMap_Call) Return(v uint) *MockPanedWidget_ConnectMap_Call {
1529 _c.Call.Return(v)1529 _c.Call.Return(v)
1530 return _c1530 return _c
1531 }1531 }
1532 1532
1533-func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectMap_Call {1533+func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()) uint) *MockPanedWidget_ConnectMap_Call {
1534 _c.Call.Return(run)1534 _c.Call.Return(run)
1535 return _c1535 return _c
1536 }1536 }
1537 1537
1538 // ConnectNotifyPosition provides a mock function for the type MockPanedWidget1538 // ConnectNotifyPosition provides a mock function for the type MockPanedWidget
1539-func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint32 {1539+func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint {
1540 ret := _mock.Called(callback)1540 ret := _mock.Called(callback)
1541 1541
1542 if len(ret) == 0 {1542 if len(ret) == 0 {
1543 panic("no return value specified for ConnectNotifyPosition")1543 panic("no return value specified for ConnectNotifyPosition")
1544 }1544 }
1545 1545
1546- var r0 uint321546+ var r0 uint
1547- if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {1547+ if returnFunc, ok := ret.Get(0).(func(func()) uint); ok {
1548 r0 = returnFunc(callback)1548 r0 = returnFunc(callback)
1549 } else {1549 } else {
1550- r0 = ret.Get(0).(uint32)1550+ r0 = ret.Get(0).(uint)
1551 }1551 }
1552 return r01552 return r0
1553 }1553 }
@@ -1576,12 +1576,12 @@ func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func
1576 return _c1576 return _c
1577 }1577 }
1578 1578
1579-func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call {1579+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint) *MockPanedWidget_ConnectNotifyPosition_Call {
1580 _c.Call.Return(v)1580 _c.Call.Return(v)
1581 return _c1581 return _c
1582 }1582 }
1583 1583
1584-func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectNotifyPosition_Call {1584+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint) *MockPanedWidget_ConnectNotifyPosition_Call {
1585 _c.Call.Return(run)1585 _c.Call.Return(run)
1586 return _c1586 return _c
1587 }1587 }
@@ -8007,18 +8007,18 @@ func (_c *MockButtonWidget_ComputePoint_Call) RunAndReturn(run func(target layou
8007 }8007 }
8008 8008
8009 // ConnectClicked provides a mock function for the type MockButtonWidget8009 // ConnectClicked provides a mock function for the type MockButtonWidget
8010-func (_mock *MockButtonWidget) ConnectClicked(callback func()) uint32 {8010+func (_mock *MockButtonWidget) ConnectClicked(callback func()) uint {
8011 ret := _mock.Called(callback)8011 ret := _mock.Called(callback)
8012 8012
8013 if len(ret) == 0 {8013 if len(ret) == 0 {
8014 panic("no return value specified for ConnectClicked")8014 panic("no return value specified for ConnectClicked")
8015 }8015 }
8016 8016
8017- var r0 uint328017+ var r0 uint
8018- if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {8018+ if returnFunc, ok := ret.Get(0).(func(func()) uint); ok {
8019 r0 = returnFunc(callback)8019 r0 = returnFunc(callback)
8020 } else {8020 } else {
8021- r0 = ret.Get(0).(uint32)8021+ r0 = ret.Get(0).(uint)
8022 }8022 }
8023 return r08023 return r0
8024 }8024 }
@@ -8047,12 +8047,12 @@ func (_c *MockButtonWidget_ConnectClicked_Call) Run(run func(callback func())) *
8047 return _c8047 return _c
8048 }8048 }
8049 8049
8050-func (_c *MockButtonWidget_ConnectClicked_Call) Return(v uint32) *MockButtonWidget_ConnectClicked_Call {8050+func (_c *MockButtonWidget_ConnectClicked_Call) Return(v uint) *MockButtonWidget_ConnectClicked_Call {
8051 _c.Call.Return(v)8051 _c.Call.Return(v)
8052 return _c8052 return _c
8053 }8053 }
8054 8054
8055-func (_c *MockButtonWidget_ConnectClicked_Call) RunAndReturn(run func(callback func()) uint32) *MockButtonWidget_ConnectClicked_Call {8055+func (_c *MockButtonWidget_ConnectClicked_Call) RunAndReturn(run func(callback func()) uint) *MockButtonWidget_ConnectClicked_Call {
8056 _c.Call.Return(run)8056 _c.Call.Return(run)
8057 return _c8057 return _c
8058 }8058 }
M internal/ui/layout/split_test.go +2 -2
@@ -29,10 +29,10 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or
29 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()29 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
30 }30 }
31 31
32- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()32+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
33 33
34 // Since allocation is 0, ApplyRatio returns false and callbacks are registered34 // Since allocation is 0, ApplyRatio returns false and callbacks are registered
35- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()35+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
36 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()36 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
37 }37 }
38 38
M internal/ui/layout/stacked.go +3 -3
@@ -35,7 +35,7 @@ type stackedPane struct {
35 isActive bool35 isActive bool
36 36
37 // Signal handler IDs for cleanup on removal37 // Signal handler IDs for cleanup on removal
38- closeClickSignalID uint3238+ closeClickSignalID uint
39 closeButton ButtonWidget // stored for signal disconnection39 closeButton ButtonWidget // stored for signal disconnection
40 40
41 // Retained callback for GestureClick to prevent GC41 // Retained callback for GestureClick to prevent GC
@@ -183,7 +183,7 @@ func (sv *StackedView) AddPane(ctx context.Context, paneID, title, faviconIconNa
183 // Returns the retained callback (to prevent GC) and close signal ID for disconnection.183 // Returns the retained callback (to prevent GC) and close signal ID for disconnection.
184 func (sv *StackedView) connectTitleBarHandlers(184 func (sv *StackedView) connectTitleBarHandlers(
185 tb titleBarComponents, paneID string,185 tb titleBarComponents, paneID string,
186-) (titleClickCallback interface{}, closeSignalID uint32) {186+) (titleClickCallback interface{}, closeSignalID uint) {
187 // Connect title bar click handler using GestureClick187 // Connect title bar click handler using GestureClick
188 // This prevents event propagation issues with nested buttons188 // This prevents event propagation issues with nested buttons
189 clickCtrl := gtk.NewGestureClick()189 clickCtrl := gtk.NewGestureClick()
@@ -259,7 +259,7 @@ func (sv *StackedView) disconnectPaneSignals(pane *stackedPane) {
259 259
260 // disconnectButtonSignal safely disconnects a signal from a button widget.260 // disconnectButtonSignal safely disconnects a signal from a button widget.
261 // Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations.261 // Returns silently if button is nil, signal ID is 0, or widget doesn't support GTK operations.
262-func disconnectButtonSignal(btn ButtonWidget, signalID uint32) {262+func disconnectButtonSignal(btn ButtonWidget, signalID uint) {
263 if btn == nil || signalID == 0 {263 if btn == nil || signalID == 0 {
264 return264 return
265 }265 }
M internal/ui/layout/stacked_test.go +2 -2
@@ -70,7 +70,7 @@ func setupPaneMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory, mockBox
70 mockTitleBar.EXPECT().AddController(mock.Anything).Once()70 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
71 71
72 // Close button click handler72 // Close button click handler
73- mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()73+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
74 74
75 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests75 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
76 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()76 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
@@ -593,7 +593,7 @@ func setupInsertPaneMocks(
593 mockTitleBar.EXPECT().AddController(mock.Anything).Once()593 mockTitleBar.EXPECT().AddController(mock.Anything).Once()
594 594
595 // Close button click handler595 // Close button click handler
596- mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint32(2)).Once()596+ mockCloseButton.EXPECT().ConnectClicked(mock.Anything).Return(uint(2)).Once()
597 597
598 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests598 // Signal disconnection calls GtkWidget() - return nil to skip actual GTK operations in tests
599 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()599 mockCloseButton.EXPECT().GtkWidget().Return(nil).Maybe()
M internal/ui/layout/tree_test.go +12 -12
@@ -122,9 +122,9 @@ func TestBuild_HorizontalSplit(t *testing.T) {
122 mockRightStackBox.EXPECT().SetVisible(true).Once()122 mockRightStackBox.EXPECT().SetVisible(true).Once()
123 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()123 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
124 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()124 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
125- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()125+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
126 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()126 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
127- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()127+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
128 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()128 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
129 129
130 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)130 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -172,9 +172,9 @@ func TestBuild_VerticalSplit(t *testing.T) {
172 mockBottomStackBox.EXPECT().SetVisible(true).Once()172 mockBottomStackBox.EXPECT().SetVisible(true).Once()
173 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()173 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()
174 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()174 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()
175- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()175+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
176 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()176 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
177- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()177+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
178 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()178 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
179 179
180 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)180 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -241,9 +241,9 @@ func TestBuild_NestedSplits(t *testing.T) {
241 mockStackBox2.EXPECT().SetVisible(true).Once()241 mockStackBox2.EXPECT().SetVisible(true).Once()
242 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()242 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()
243 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()243 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()
244- mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()244+ mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
245 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()245 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
246- mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()246+ mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
247 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()247 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
248 248
249 // Outer split (horizontal) gets inner paned and pane3249 // Outer split (horizontal) gets inner paned and pane3
@@ -255,9 +255,9 @@ func TestBuild_NestedSplits(t *testing.T) {
255 mockStackBox3.EXPECT().SetVisible(true).Once()255 mockStackBox3.EXPECT().SetVisible(true).Once()
256 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()256 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
257 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()257 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()
258- mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()258+ mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
259 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()259 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
260- mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()260+ mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
261 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()261 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
262 262
263 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)263 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -417,9 +417,9 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
417 mockRightStackBox.EXPECT().SetVisible(true).Once()417 mockRightStackBox.EXPECT().SetVisible(true).Once()
418 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()418 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
419 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()419 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
420- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()420+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
421 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()421 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
422- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()422+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
423 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()423 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
424 424
425 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)425 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -542,9 +542,9 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
542 mockRightStackBox.EXPECT().SetVisible(true).Once()542 mockRightStackBox.EXPECT().SetVisible(true).Once()
543 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()543 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
544 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()544 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
545- mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()545+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once()
546 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()546 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
547- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()547+ mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once()
548 548
549 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()549 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
550 550
M internal/ui/layout/widgets.go +3 -3
@@ -95,8 +95,8 @@ type PanedWidget interface {
95 GetShrinkEndChild() bool95 GetShrinkEndChild() bool
96 96
97 // Signals97 // Signals
98- ConnectMap(callback func()) uint3298+ ConnectMap(callback func()) uint
99- ConnectNotifyPosition(callback func()) uint3299+ ConnectNotifyPosition(callback func()) uint
100 100
101 // Tick callback for frame-based updates (returns signal ID)101 // Tick callback for frame-based updates (returns signal ID)
102 // Callback returns true to continue, false to stop102 // Callback returns true to continue, false to stop
@@ -182,7 +182,7 @@ type ButtonWidget interface {
182 SetIconName(iconName string)182 SetIconName(iconName string)
183 183
184 // Connect click handler, returns signal ID for disconnection184 // Connect click handler, returns signal ID for disconnection
185- ConnectClicked(callback func()) uint32185+ ConnectClicked(callback func()) uint
186 }186 }
187 187
188 // Paintable represents a graphics texture that can be displayed in an image.188 // Paintable represents a graphics texture that can be displayed in an image.
M internal/ui/theme/css.go +9 -2
@@ -527,12 +527,19 @@ func generatePaneCSS(p Palette) string {
527 min-width: 32px;527 min-width: 32px;
528 min-height: 32px;528 min-height: 32px;
529 color: var(--muted);529 color: var(--muted);
530+ opacity: 0.4;
530 }531 }
531 532
532 .loading-skeleton-logo {533 .loading-skeleton-logo {
533 opacity: 0.08;534 opacity: 0.08;
534- min-width: 512px;535+ min-width: 256px;
535- min-height: 512px;536+ min-height: 256px;
537+}
538+
539+.loading-skeleton-version {
540+ opacity: 0.25;
541+ font-size: 13px;
542+ color: var(--muted);
536 }543 }
537 544
538 /* Pane border - default transparent */545 /* Pane border - default transparent */
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