detached 1bac075

fix(systemviews): resolve follow-up review findings

commit
1bac075c84c86dff017d315b0180ea7ef2e48571
author
brice · · 2026-04-24 09:54 UTC
committer
brice · · 2026-04-25 07:40 UTC
parent
e074a73
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
102 files · +4527 -1194
M .github/workflows/aur.yml +1 -1
@@ -51,7 +51,7 @@ jobs:
51 "arch=('x86_64')" \51 "arch=('x86_64')" \
52 'url="https://github.com/bnema/dumber"' \52 'url="https://github.com/bnema/dumber"' \
53 "license=('MIT')" \53 "license=('MIT')" \
54- "makedepends=('go>=1.25' 'git')" \54+ "makedepends=('go>=1.26' 'git' 'brotli')" \
55 "depends=('gtk4' 'webkitgtk-6.0')" \55 "depends=('gtk4' 'webkitgtk-6.0')" \
56 'optdepends=(' \56 'optdepends=(' \
57 " 'gst-plugins-base: Base media codecs'" \57 " 'gst-plugins-base: Base media codecs'" \
M .github/workflows/ci.yml +6 -6
@@ -47,7 +47,7 @@ jobs:
47 - name: Install dependencies47 - name: Install dependencies
48 run: |48 run: |
49 pacman -Syu --noconfirm49 pacman -Syu --noconfirm
50- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel50+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
51 - uses: actions/checkout@v651 - uses: actions/checkout@v6
52 - name: Cache Go modules52 - name: Cache Go modules
53 uses: actions/cache@v553 uses: actions/cache@v5
@@ -63,8 +63,8 @@ jobs:
63 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}63 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
64 restore-keys: |64 restore-keys: |
65 ${{ runner.os }}-go-build-65 ${{ runner.os }}-go-build-
66- - name: Build systemviews assets66+ - name: Build application and systemviews assets
67- run: make build-systemviews67+ run: make build
68 - name: Install golangci-lint68 - name: Install golangci-lint
69 run: |69 run: |
70 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest70 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest
@@ -83,7 +83,7 @@ jobs:
83 - name: Install dependencies83 - name: Install dependencies
84 run: |84 run: |
85 pacman -Syu --noconfirm85 pacman -Syu --noconfirm
86- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel86+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli
87 - uses: actions/checkout@v687 - uses: actions/checkout@v6
88 - name: Cache Go modules88 - name: Cache Go modules
89 uses: actions/cache@v589 uses: actions/cache@v5
@@ -99,8 +99,8 @@ jobs:
99 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}99 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
100 restore-keys: |100 restore-keys: |
101 ${{ runner.os }}-go-build-101 ${{ runner.os }}-go-build-
102- - name: Build systemviews assets102+ - name: Build application and systemviews assets
103- run: make build-systemviews103+ run: make build
104 - name: Run tests104 - name: Run tests
105 env:105 env:
106 GDK_BACKEND: wayland106 GDK_BACKEND: wayland
M .gitignore +1 -0
@@ -65,6 +65,7 @@ tmp/go-mod/
65 tmp/65 tmp/
66 assets/webui66 assets/webui
67 assets/systemviews/systemviews.wasm67 assets/systemviews/systemviews.wasm
68+assets/systemviews/systemviews.wasm.br
68 assets/systemviews/wasm_exec.js69 assets/systemviews/wasm_exec.js
69 70
70 # Node/TypeScript related71 # Node/TypeScript related
M .mockery.yaml +1 -0
@@ -88,6 +88,7 @@ packages:
88 UpdateChecker: {}88 UpdateChecker: {}
89 UpdateDownloader: {}89 UpdateDownloader: {}
90 WebUIConfigSaver: {}90 WebUIConfigSaver: {}
91+ SystemviewConfigReader: {}
91 XDGPaths: {}92 XDGPaths: {}
92 github.com/bnema/dumber/internal/domain/repository:93 github.com/bnema/dumber/internal/domain/repository:
93 config:94 config:
M CONTRIBUTING.md +1 -1
@@ -35,7 +35,7 @@ git push origin my-fix
35 3. Run `go fmt` on any Go code changes35 3. Run `go fmt` on any Go code changes
36 36
37 **Systemviews assets:**37 **Systemviews assets:**
38-1. Run `make build-systemviews` to regenerate embedded WASM assets38+1. If you modified systemviews files such as `cmd/systemviews/` or `.templ` components, run `make build-systemviews` to regenerate embedded WASM assets
39 39
40 Keep commits focused and use single-line commit messages.40 Keep commits focused and use single-line commit messages.
41 41
M Makefile +3 -2
@@ -53,9 +53,10 @@ generate-systemviews: ## Generate Go code from systemviews templ components
53 53
54 build-systemviews: generate-systemviews ## Build the WASM systemviews runtime54 build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
55 @echo "Building systemviews wasm assets..."55 @echo "Building systemviews wasm assets..."
56+ @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
56 @mkdir -p assets/systemviews57 @mkdir -p assets/systemviews
57 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js58 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
58- GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews59+ GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
59 brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br60 brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br
60 @echo "Systemviews build complete"61 @echo "Systemviews build complete"
61 62
@@ -145,7 +146,7 @@ clean: ## Clean build artifacts
145 @echo "Cleaning build artifacts..."146 @echo "Cleaning build artifacts..."
146 rm -rf $(DIST_DIR)147 rm -rf $(DIST_DIR)
147 rm -f $(BINARY_NAME)148 rm -f $(BINARY_NAME)
148- rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm149+ rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br
149 rm -f coverage.out coverage.html150 rm -f coverage.out coverage.html
150 go clean -cache151 go clean -cache
151 go clean -testcache152 go clean -testcache
M assets/systemviews/index.html +54 -17
@@ -8,27 +8,64 @@
8 <link rel="stylesheet" href="./systemviews.css">8 <link rel="stylesheet" href="./systemviews.css">
9 <script src="./wasm_exec.js"></script>9 <script src="./wasm_exec.js"></script>
10 <script>10 <script>
11- const go = new Go();11+ function renderWasmError(prefixText, err) {
12- WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)12+ const render = () => {
13- .then((result) => go.run(result.instance))
14- .catch((err) => {
15 const app = document.getElementById('app');13 const app = document.getElementById('app');
16- if (app) {14+ if (!app) return;
17- app.innerHTML = '';15+ app.innerHTML = '';
18- const message = document.createElement('div');16+ const message = document.createElement('div');
19- message.className = 'sv-error';17+ message.className = 'sv-error';
20- const prefix = document.createElement('span');18+ const prefix = document.createElement('span');
21- prefix.textContent = 'Failed to load systemviews WebAssembly: ';19+ prefix.textContent = prefixText;
22- const details = document.createElement('span');20+ const details = document.createElement('span');
23- details.textContent = err && err.message ? err.message : String(err);21+ details.textContent = err && err.message ? err.message : String(err || 'unsupported browser');
24- message.append(prefix, details);22+ message.append(prefix, details);
25- app.append(message);23+ app.append(message);
26- }24+ };
27- console.error('WASM load error:', err);25+ if (document.readyState === 'loading' && !document.getElementById('app')) {
28- });26+ document.addEventListener('DOMContentLoaded', render, { once: true });
27+ } else {
28+ render();
29+ }
30+ console.error(prefixText, err);
31+ }
32+
33+ function instantiateWasm(go, wasmURL) {
34+ const instantiateFromBytes = () => fetch(wasmURL)
35+ .then((response) => {
36+ if (!response.ok) {
37+ throw new Error(`HTTP ${response.status} loading ${wasmURL}`);
38+ }
39+ return response.arrayBuffer();
40+ })
41+ .then((bytes) => WebAssembly.instantiate(bytes, go.importObject));
42+
43+ if (!WebAssembly.instantiateStreaming) {
44+ return instantiateFromBytes();
45+ }
46+ return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject)
47+ .catch((err) => {
48+ console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err);
49+ return instantiateFromBytes();
50+ });
51+ }
52+
53+ if (typeof WebAssembly !== 'object') {
54+ renderWasmError('WebAssembly is not supported in this browser: ');
55+ } else if (typeof Go !== 'function') {
56+ renderWasmError('Go WebAssembly runtime failed to load: ');
57+ } else {
58+ const go = new Go();
59+ instantiateWasm(go, './systemviews.wasm')
60+ .then((result) => go.run(result.instance))
61+ .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err));
62+ }
29 </script>63 </script>
30 </head>64 </head>
31 <body>65 <body>
32 <main id="app">Loading systemviews…</main>66 <main id="app">Loading systemviews…</main>
67+ <noscript>
68+ <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p>
69+ </noscript>
33 </body>70 </body>
34 </html>71 </html>
M assets/systemviews/systemviews.css +159 -31
@@ -1,9 +1,17 @@
1 :root {1 :root {
2- color-scheme: dark light;2+ color-scheme: dark;
3- --sv-bg: #0f1115;3+ --sv-background: #0f1115;
4- --sv-fg: #e8ebf0;4+ --sv-surface: #171a21;
5+ --sv-surface-variant: #1d2129;
6+ --sv-text: #e8ebf0;
5 --sv-muted: #9aa3b2;7 --sv-muted: #9aa3b2;
6 --sv-border: #2a313d;8 --sv-border: #2a313d;
9+ --sv-success: var(--sv-accent, #8ab4ff);
10+ --sv-danger: #ef4444;
11+ --sv-danger-text: #fecaca;
12+ --sv-radius-card: 0.5rem;
13+ --sv-radius-control: 0.35rem;
14+ --sv-radius-chip: 0.45rem;
7 }15 }
8 16
9 body {17 body {
@@ -16,8 +24,28 @@ body {
16 24
17 .sv-app {25 .sv-app {
18 min-height: 100vh;26 min-height: 100vh;
27+ background: var(--sv-background, #0f1115);
28+ color: var(--sv-text, #e8ebf0);
29+}
30+
31+.sv-app.sv-dark {
32+ color-scheme: dark;
19 }33 }
20 34
35+.sv-app.sv-light {
36+ color-scheme: light;
37+ --sv-background: #f8fafc;
38+ --sv-surface: #ffffff;
39+ --sv-surface-variant: #eef2f7;
40+ --sv-text: #111827;
41+ --sv-muted: #64748b;
42+ --sv-border: #d7dee8;
43+ --sv-primary: #2563eb;
44+ --sv-danger: #dc2626;
45+ --sv-danger-text: #7f1d1d;
46+}
47+
48+
21 .sv-shell {49 .sv-shell {
22 display: grid;50 display: grid;
23 gap: 1rem;51 gap: 1rem;
@@ -52,7 +80,7 @@ body {
52 .sv-list-row {80 .sv-list-row {
53 padding: 0.75rem;81 padding: 0.75rem;
54 border: 1px solid var(--sv-border, #2a313d);82 border: 1px solid var(--sv-border, #2a313d);
55- border-radius: 0.65rem;83+ border-radius: var(--sv-radius-card, 0.5rem);
56 background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);84 background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);
57 }85 }
58 86
@@ -81,14 +109,14 @@ body {
81 .sv-header {109 .sv-header {
82 background: var(--sv-surface-variant, #1d2129);110 background: var(--sv-surface-variant, #1d2129);
83 border: 1px solid var(--sv-border, #2a313d);111 border: 1px solid var(--sv-border, #2a313d);
84- border-radius: 0.75rem;112+ border-radius: var(--sv-radius-card, 0.5rem);
85 padding: 1rem;113 padding: 1rem;
86 }114 }
87 115
88 .sv-section {116 .sv-section {
89 background: var(--sv-surface, #171a21);117 background: var(--sv-surface, #171a21);
90 border: 1px solid var(--sv-border, #2a313d);118 border: 1px solid var(--sv-border, #2a313d);
91- border-radius: 0.75rem;119+ border-radius: var(--sv-radius-card, 0.5rem);
92 padding: 1rem;120 padding: 1rem;
93 }121 }
94 122
@@ -101,7 +129,7 @@ body {
101 margin-top: 1rem;129 margin-top: 1rem;
102 padding: 1rem;130 padding: 1rem;
103 border: 1px solid var(--sv-border, #2a313d);131 border: 1px solid var(--sv-border, #2a313d);
104- border-radius: 0.75rem;132+ border-radius: var(--sv-radius-card, 0.5rem);
105 background: var(--sv-surface, #171a21);133 background: var(--sv-surface, #171a21);
106 }134 }
107 135
@@ -156,7 +184,7 @@ body {
156 display: inline-flex;184 display: inline-flex;
157 align-items: center;185 align-items: center;
158 padding: 0.2rem 0.45rem;186 padding: 0.2rem 0.45rem;
159- border-radius: 0.45rem;187+ border-radius: var(--sv-radius-control, 0.35rem);
160 border: 1px solid var(--sv-border, #2a313d);188 border: 1px solid var(--sv-border, #2a313d);
161 background: var(--sv-surface-variant, #1d2129);189 background: var(--sv-surface-variant, #1d2129);
162 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;190 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -187,7 +215,7 @@ body {
187 gap: 0.15rem;215 gap: 0.15rem;
188 padding: 0.75rem;216 padding: 0.75rem;
189 border: 1px solid var(--sv-border, #2a313d);217 border: 1px solid var(--sv-border, #2a313d);
190- border-radius: 0.65rem;218+ border-radius: var(--sv-radius-card, 0.5rem);
191 background: var(--sv-surface-variant, #1d2129);219 background: var(--sv-surface-variant, #1d2129);
192 }220 }
193 221
@@ -216,7 +244,7 @@ body {
216 gap: 0.15rem;244 gap: 0.15rem;
217 padding: 0.45rem 0.65rem;245 padding: 0.45rem 0.65rem;
218 border: 1px solid var(--sv-border, #2a313d);246 border: 1px solid var(--sv-border, #2a313d);
219- border-radius: 999px;247+ border-radius: var(--sv-radius-chip, 0.45rem);
220 background: var(--sv-surface-variant, #1d2129);248 background: var(--sv-surface-variant, #1d2129);
221 }249 }
222 250
@@ -260,9 +288,9 @@ body {
260 }288 }
261 289
262 .sv-error {290 .sv-error {
263- border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));291+ border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d));
264- background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));292+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
265- border-radius: 0.75rem;293+ border-radius: var(--sv-radius-card, 0.5rem);
266 color: var(--sv-text, #e8ebf0);294 color: var(--sv-text, #e8ebf0);
267 padding: 1rem;295 padding: 1rem;
268 }296 }
@@ -304,18 +332,26 @@ body {
304 font-weight: 600;332 font-weight: 600;
305 }333 }
306 .sv-alert {334 .sv-alert {
307- border-radius: 0.75rem;335+ border-radius: var(--sv-radius-card, 0.5rem);
308 padding: 0.75rem 1rem;336 padding: 0.75rem 1rem;
309 border: 1px solid var(--sv-border, #2a313d);337 border: 1px solid var(--sv-border, #2a313d);
338+ transition: opacity 180ms ease, transform 180ms ease;
339+}
340+
341+.sv-alert-dismiss {
342+ opacity: 0;
343+ transform: translateY(-0.25rem);
310 }344 }
311 345
312 .sv-alert-success {346 .sv-alert-success {
313- background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21));347+ background: color-mix(in srgb, var(--sv-success, #8ab4ff) 14%, var(--sv-surface, #171a21));
348+ border-color: color-mix(in srgb, var(--sv-success, #8ab4ff) 62%, var(--sv-border, #2a313d));
314 }349 }
315 350
316 .sv-alert-error {351 .sv-alert-error {
317- background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));352+ color: var(--sv-danger-text, #fecaca);
318- border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));353+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
354+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 72%, var(--sv-border, #2a313d));
319 }355 }
320 356
321 .sv-toolbar,357 .sv-toolbar,
@@ -349,7 +385,7 @@ body {
349 385
350 .sv-search-label input {386 .sv-search-label input {
351 border: 1px solid var(--sv-border, #2a313d);387 border: 1px solid var(--sv-border, #2a313d);
352- border-radius: 0.55rem;388+ border-radius: var(--sv-radius-control, 0.35rem);
353 padding: 0.55rem 0.65rem;389 padding: 0.55rem 0.65rem;
354 background: var(--sv-surface-variant, #1d2129);390 background: var(--sv-surface-variant, #1d2129);
355 color: var(--sv-text, #e8ebf0);391 color: var(--sv-text, #e8ebf0);
@@ -357,12 +393,13 @@ body {
357 }393 }
358 394
359 .sv-button {395 .sv-button {
396+ box-sizing: border-box;
360 display: inline-flex;397 display: inline-flex;
361 align-items: center;398 align-items: center;
362 justify-content: center;399 justify-content: center;
363 min-height: 2rem;400 min-height: 2rem;
364 border: 1px solid var(--sv-accent, #8ab4ff);401 border: 1px solid var(--sv-accent, #8ab4ff);
365- border-radius: 0.55rem;402+ border-radius: var(--sv-radius-control, 0.35rem);
366 padding: 0.35rem 0.65rem;403 padding: 0.35rem 0.65rem;
367 background: var(--sv-accent, #8ab4ff);404 background: var(--sv-accent, #8ab4ff);
368 color: var(--sv-background, #0f1115);405 color: var(--sv-background, #0f1115);
@@ -379,13 +416,18 @@ body {
379 }416 }
380 417
381 .sv-button-danger {418 .sv-button-danger {
382- border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d));419+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d));
383- background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129));420+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129));
384- color: #fecaca;421+ color: var(--sv-danger-text, #fecaca);
385 }422 }
386 423
387 .sv-button-danger:hover {424 .sv-button-danger:hover {
388- background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129));425+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 24%, var(--sv-surface-variant, #1d2129));
426+}
427+
428+.sv-button-danger:disabled {
429+ color: var(--sv-danger-text, #fecaca);
430+ opacity: 0.7;
389 }431 }
390 432
391 .sv-button:focus-visible,433 .sv-button:focus-visible,
@@ -414,7 +456,7 @@ body {
414 gap: 0.35rem;456 gap: 0.35rem;
415 align-items: center;457 align-items: center;
416 border: 1px solid var(--sv-border, #2a313d);458 border: 1px solid var(--sv-border, #2a313d);
417- border-radius: 999px;459+ border-radius: var(--sv-radius-chip, 0.45rem);
418 padding: 0.25rem 0.55rem;460 padding: 0.25rem 0.55rem;
419 background: var(--sv-surface-variant, #1d2129);461 background: var(--sv-surface-variant, #1d2129);
420 }462 }
@@ -446,7 +488,7 @@ body {
446 }488 }
447 489
448 .sv-danger {490 .sv-danger {
449- color: #fecaca;491+ color: var(--sv-danger-text, #fecaca);
450 }492 }
451 493
452 .sv-icon-button:hover,494 .sv-icon-button:hover,
@@ -458,12 +500,12 @@ body {
458 .sv-history-item.sv-focused {500 .sv-history-item.sv-focused {
459 outline: 2px solid var(--sv-accent, #8ab4ff);501 outline: 2px solid var(--sv-accent, #8ab4ff);
460 outline-offset: 0.35rem;502 outline-offset: 0.35rem;
461- border-radius: 0.5rem;503+ border-radius: var(--sv-radius-control, 0.35rem);
462 }504 }
463 505
464 .sv-key-hints kbd {506 .sv-key-hints kbd {
465 border: 1px solid var(--sv-border, #2a313d);507 border: 1px solid var(--sv-border, #2a313d);
466- border-radius: 0.35rem;508+ border-radius: 0.25rem;
467 padding: 0.1rem 0.3rem;509 padding: 0.1rem 0.3rem;
468 background: var(--sv-surface-variant, #1d2129);510 background: var(--sv-surface-variant, #1d2129);
469 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;511 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -481,7 +523,7 @@ body {
481 .sv-card-form {523 .sv-card-form {
482 padding: 0.75rem;524 padding: 0.75rem;
483 border: 1px solid var(--sv-border, #2a313d);525 border: 1px solid var(--sv-border, #2a313d);
484- border-radius: 0.75rem;526+ border-radius: var(--sv-radius-card, 0.5rem);
485 background: var(--sv-surface-variant, #1d2129);527 background: var(--sv-surface-variant, #1d2129);
486 }528 }
487 529
@@ -503,13 +545,37 @@ body {
503 .sv-fieldset input,545 .sv-fieldset input,
504 .sv-fieldset select {546 .sv-fieldset select {
505 border: 1px solid var(--sv-border, #2a313d);547 border: 1px solid var(--sv-border, #2a313d);
506- border-radius: 0.55rem;548+ border-radius: var(--sv-radius-control, 0.35rem);
507 padding: 0.5rem 0.6rem;549 padding: 0.5rem 0.6rem;
508 background: var(--sv-surface-variant, #1d2129);550 background: var(--sv-surface-variant, #1d2129);
509 color: var(--sv-text, #e8ebf0);551 color: var(--sv-text, #e8ebf0);
510 font: inherit;552 font: inherit;
511 }553 }
512 554
555+.sv-card-form input[type="color"],
556+.sv-inline-form input[type="color"],
557+.sv-fieldset input[type="color"] {
558+ width: 4rem;
559+ min-width: 4rem;
560+ height: 2.1rem;
561+ padding: 0.15rem;
562+ background: transparent;
563+ cursor: pointer;
564+}
565+
566+.sv-card-form input[type="color"]::-webkit-color-swatch-wrapper,
567+.sv-inline-form input[type="color"]::-webkit-color-swatch-wrapper,
568+.sv-fieldset input[type="color"]::-webkit-color-swatch-wrapper {
569+ padding: 0;
570+}
571+
572+.sv-card-form input[type="color"]::-webkit-color-swatch,
573+.sv-inline-form input[type="color"]::-webkit-color-swatch,
574+.sv-fieldset input[type="color"]::-webkit-color-swatch {
575+ border: 0;
576+ border-radius: calc(var(--sv-radius-control, 0.35rem) - 0.12rem);
577+}
578+
513 .sv-config-form,579 .sv-config-form,
514 .sv-fieldset {580 .sv-fieldset {
515 display: flex;581 display: flex;
@@ -523,7 +589,7 @@ body {
523 margin: 0;589 margin: 0;
524 padding: 0.75rem;590 padding: 0.75rem;
525 border: 1px solid var(--sv-border, #2a313d);591 border: 1px solid var(--sv-border, #2a313d);
526- border-radius: 0.65rem;592+ border-radius: var(--sv-radius-card, 0.5rem);
527 }593 }
528 594
529 .sv-fieldset legend {595 .sv-fieldset legend {
@@ -545,11 +611,73 @@ body {
545 margin-top: 0.75rem;611 margin-top: 0.75rem;
546 }612 }
547 613
614+.sv-history-row-actions .sv-button {
615+ min-width: 4.75rem;
616+}
617+
548 .sv-button-active {618 .sv-button-active {
549 border-color: var(--sv-accent, #8ab4ff);619 border-color: var(--sv-accent, #8ab4ff);
550 box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;620 box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;
551 }621 }
552 622
623+.sv-tag-picker {
624+ display: grid;
625+ gap: 0.45rem;
626+ min-width: min(22rem, 100%);
627+ margin: 0;
628+ padding: 0;
629+ border: 0;
630+}
631+
632+.sv-tag-picker legend {
633+ color: var(--sv-muted, #9aa3b2);
634+ font-size: 12px;
635+ font-weight: 600;
636+}
637+
638+.sv-card-form .sv-tag-toggle,
639+.sv-tag-toggle {
640+ display: inline-flex;
641+ min-width: 0;
642+ color: inherit;
643+ font-size: inherit;
644+ font-weight: inherit;
645+ cursor: pointer;
646+}
647+
648+.sv-tag-toggle input {
649+ position: absolute;
650+ inline-size: 1px;
651+ block-size: 1px;
652+ opacity: 0;
653+ pointer-events: none;
654+}
655+
656+.sv-tag-toggle span,
657+.sv-tag-button {
658+ display: inline-flex;
659+ align-items: center;
660+ gap: 0.35rem;
661+}
662+
663+.sv-tag-toggle > span {
664+ min-height: 1.45rem;
665+ border: 1px solid var(--sv-border, #2a313d);
666+ border-radius: var(--sv-radius-chip, 0.45rem);
667+ padding: 0.16rem 0.45rem;
668+ background: var(--sv-surface-variant, #1d2129);
669+ color: var(--sv-text, #e8ebf0);
670+ font-size: 12px;
671+ font-weight: 700;
672+}
673+
674+.sv-tag-toggle input:checked + span,
675+.sv-tag-button-active {
676+ border-color: var(--sv-accent, #8ab4ff);
677+ background: var(--sv-accent, #8ab4ff);
678+ color: var(--sv-background, #0f1115);
679+}
680+
553 .sv-favorite-item {681 .sv-favorite-item {
554 display: grid;682 display: grid;
555 gap: 0.75rem;683 gap: 0.75rem;
@@ -581,7 +709,7 @@ body {
581 .sv-favorite-item.sv-focused {709 .sv-favorite-item.sv-focused {
582 outline: 2px solid var(--sv-accent, #8ab4ff);710 outline: 2px solid var(--sv-accent, #8ab4ff);
583 outline-offset: 0.35rem;711 outline-offset: 0.35rem;
584- border-radius: 0.5rem;712+ border-radius: var(--sv-radius-control, 0.35rem);
585 }713 }
586 714
587 @media (max-width: 720px) {715 @media (max-width: 720px) {
D assets/systemviews/systemviews.wasm.br +0 -0 binary file: no text diff

binary file: no text diff

M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9-makedepends=('go>=1.26' 'git')9+makedepends=('go>=1.26' 'git' 'brotli')
10 depends=('gtk4' 'webkitgtk-6.0')10 depends=('gtk4' 'webkitgtk-6.0')
11 optdepends=(11 optdepends=(
12 'gst-plugins-base: Base media codecs'12 'gst-plugins-base: Base media codecs'
M cmd/systemviews/bootstrap.go +4 -1
@@ -14,11 +14,12 @@ type bridgeServices interface {
14 }14 }
15 15
16 func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {16 func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {
17+ route := systemviews.ParseRoute(locationURI)
17 return systemviews.NewApp(systemviews.Dependencies{18 return systemviews.NewApp(systemviews.Dependencies{
18 DOM: dom,19 DOM: dom,
19 History: bridge,20 History: bridge,
20 Favorites: bridge,21 Favorites: bridge,
21- Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)},22+ Config: bridgeConfigProxy{bridge: bridge, route: route},
22 LocationURI: locationURI,23 LocationURI: locationURI,
23 })24 })
24 }25 }
@@ -41,6 +42,8 @@ func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error
41 }42 }
42 43
43 func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {44 func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
45+ // Keybindings are only rendered by dumb://config; skip the bridge load for
46+ // other systemview routes to keep initial history/favorites loads cheap.
44 if p.route != systemviews.RouteConfig {47 if p.route != systemviews.RouteConfig {
45 return port.KeybindingsConfig{}, nil48 return port.KeybindingsConfig{}, nil
46 }49 }
M cmd/systemviews/bootstrap_test.go +3 -2
@@ -96,7 +96,8 @@ func (f *fakeDOM) Mount(html string) error {
96 return nil96 return nil
97 }97 }
98 98
99-// Handwritten fake to capture bridge state for stateful render assertions.99+// Handwritten fake intentionally tracks state across history, favorites, config,
100+// and keybindings boundaries for the composite bootstrap assertions.
100 type fakeBridgeService struct {101 type fakeBridgeService struct {
101 calledHistory bool102 calledHistory bool
102 calledFavorites bool103 calledFavorites bool
@@ -166,7 +167,7 @@ func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { retu
166 167
167 func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }168 func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }
168 169
169-func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) {170+func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) {
170 return nil, nil171 return nil, nil
171 }172 }
172 173
M cmd/systemviews/main.go +5 -2
@@ -13,8 +13,11 @@ func main() {
13 bridge := systemviewsbridge.NewBrowserClient()13 bridge := systemviewsbridge.NewBrowserClient()
14 href := ""14 href := ""
15 location := js.Global().Get("location")15 location := js.Global().Get("location")
16- if location.Type() != js.TypeUndefined && location.Type() != js.TypeNull {16+ if location.Truthy() {
17- href = location.Get("href").String()17+ hrefValue := location.Get("href")
18+ if hrefValue.Truthy() && hrefValue.Type() == js.TypeString {
19+ href = hrefValue.String()
20+ }
18 }21 }
19 app := newBridgeApp(systemviews.NewDOM(), href, bridge)22 app := newBridgeApp(systemviews.NewDOM(), href, bridge)
20 23
M docs/reference/keybindings.md +5 -5
@@ -112,19 +112,19 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
112 split-right = ["arrowright", "r"]112 split-right = ["arrowright", "r"]
113 close-pane = ["x", "q"]113 close-pane = ["x", "q"]
114 114
115-[workspace.shortcuts.actions.close_pane]115+[workspace.shortcuts.actions.close-pane]
116 keys = ["ctrl+w"]116 keys = ["ctrl+w"]
117 117
118-[workspace.shortcuts.actions.toggle_floating_pane]118+[workspace.shortcuts.actions.toggle-floating-pane]
119 keys = ["alt+f"]119 keys = ["alt+f"]
120 120
121-[workspace.shortcuts.actions.toggle_history_systemview]121+[workspace.shortcuts.actions.toggle-history-systemview]
122 keys = ["ctrl+h"]122 keys = ["ctrl+h"]
123 123
124-[workspace.shortcuts.actions.toggle_favorites_systemview]124+[workspace.shortcuts.actions.toggle-favorites-systemview]
125 keys = []125 keys = []
126 126
127-[workspace.shortcuts.actions.toggle_config_systemview]127+[workspace.shortcuts.actions.toggle-config-systemview]
128 keys = []128 keys = []
129 129
130 [workspace.floating_pane]130 [workspace.floating_pane]
M go.mod +1 -1
@@ -5,7 +5,7 @@ go 1.26.1
5 require (5 require (
6 github.com/a-h/templ v0.3.10016 github.com/a-h/templ v0.3.1001
7 github.com/bnema/purego v0.11.0-bnema.27 github.com/bnema/purego v0.11.0-bnema.2
8- github.com/bnema/purego-cef v0.10.28+ github.com/bnema/purego-cef v0.11.0
9 github.com/bnema/purego-ffmpeg v0.3.19 github.com/bnema/purego-ffmpeg v0.3.1
10 github.com/bnema/purego-pipewire v0.1.210 github.com/bnema/purego-pipewire v0.1.2
11 github.com/bnema/purego-sqlite v0.1.211 github.com/bnema/purego-sqlite v0.1.2
M go.sum +2 -0 generated file: collapsed
M internal/application/port/homepage.go +1 -1
@@ -61,7 +61,7 @@ type HomepageFavorites interface {
61 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)61 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
62 Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error62 Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
63 GetAllFolders(ctx context.Context) ([]*entity.Folder, error)63 GetAllFolders(ctx context.Context) ([]*entity.Folder, error)
64- CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)64+ CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error)
65 DeleteFolder(ctx context.Context, id entity.FolderID) error65 DeleteFolder(ctx context.Context, id entity.FolderID) error
66 UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error66 UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error
67 GetAllTags(ctx context.Context) ([]*entity.Tag, error)67 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
M internal/application/port/mocks/mocks.go +167 -14
@@ -7240,8 +7240,8 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C
7240 }7240 }
7241 7241
7242 // CreateFolder provides a mock function for the type MockHomepageFavorites7242 // CreateFolder provides a mock function for the type MockHomepageFavorites
7243-func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {7243+func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
7244- ret := _mock.Called(ctx, name, parentID)7244+ ret := _mock.Called(ctx, name, icon, parentID)
7245 7245
7246 if len(ret) == 0 {7246 if len(ret) == 0 {
7247 panic("no return value specified for CreateFolder")7247 panic("no return value specified for CreateFolder")
@@ -7249,18 +7249,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin
7249 7249
7250 var r0 *entity.Folder7250 var r0 *entity.Folder
7251 var r1 error7251 var r1 error
7252- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok {7252+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok {
7253- return returnFunc(ctx, name, parentID)7253+ return returnFunc(ctx, name, icon, parentID)
7254 }7254 }
7255- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok {7255+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok {
7256- r0 = returnFunc(ctx, name, parentID)7256+ r0 = returnFunc(ctx, name, icon, parentID)
7257 } else {7257 } else {
7258 if ret.Get(0) != nil {7258 if ret.Get(0) != nil {
7259 r0 = ret.Get(0).(*entity.Folder)7259 r0 = ret.Get(0).(*entity.Folder)
7260 }7260 }
7261 }7261 }
7262- if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok {7262+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok {
7263- r1 = returnFunc(ctx, name, parentID)7263+ r1 = returnFunc(ctx, name, icon, parentID)
7264 } else {7264 } else {
7265 r1 = ret.Error(1)7265 r1 = ret.Error(1)
7266 }7266 }
@@ -7275,12 +7275,13 @@ type MockHomepageFavorites_CreateFolder_Call struct {
7275 // CreateFolder is a helper method to define mock.On call7275 // CreateFolder is a helper method to define mock.On call
7276 // - ctx context.Context7276 // - ctx context.Context
7277 // - name string7277 // - name string
7278+// - icon string
7278 // - parentID *entity.FolderID7279 // - parentID *entity.FolderID
7279-func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {7280+func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {
7280- return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)}7281+ return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)}
7281 }7282 }
7282 7283
7283-func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {7284+func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {
7284 _c.Call.Run(func(args mock.Arguments) {7285 _c.Call.Run(func(args mock.Arguments) {
7285 var arg0 context.Context7286 var arg0 context.Context
7286 if args[0] != nil {7287 if args[0] != nil {
@@ -7290,14 +7291,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont
7290 if args[1] != nil {7291 if args[1] != nil {
7291 arg1 = args[1].(string)7292 arg1 = args[1].(string)
7292 }7293 }
7293- var arg2 *entity.FolderID7294+ var arg2 string
7294 if args[2] != nil {7295 if args[2] != nil {
7295- arg2 = args[2].(*entity.FolderID)7296+ arg2 = args[2].(string)
7297+ }
7298+ var arg3 *entity.FolderID
7299+ if args[3] != nil {
7300+ arg3 = args[3].(*entity.FolderID)
7296 }7301 }
7297 run(7302 run(
7298 arg0,7303 arg0,
7299 arg1,7304 arg1,
7300 arg2,7305 arg2,
7306+ arg3,
7301 )7307 )
7302 })7308 })
7303 return _c7309 return _c
@@ -7308,7 +7314,7 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder,
7308 return _c7314 return _c
7309 }7315 }
7310 7316
7311-func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {7317+func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {
7312 _c.Call.Return(run)7318 _c.Call.Return(run)
7313 return _c7319 return _c
7314 }7320 }
@@ -12079,6 +12085,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context.
12079 return _c12085 return _c
12080 }12086 }
12081 12087
12088+// NewMockSystemviewConfigReader creates a new instance of MockSystemviewConfigReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12089+// The first argument is typically a *testing.T value.
12090+func NewMockSystemviewConfigReader(t interface {
12091+ mock.TestingT
12092+ Cleanup(func())
12093+}) *MockSystemviewConfigReader {
12094+ mock := &MockSystemviewConfigReader{}
12095+ mock.Mock.Test(t)
12096+
12097+ t.Cleanup(func() { mock.AssertExpectations(t) })
12098+
12099+ return mock
12100+}
12101+
12102+// MockSystemviewConfigReader is an autogenerated mock type for the SystemviewConfigReader type
12103+type MockSystemviewConfigReader struct {
12104+ mock.Mock
12105+}
12106+
12107+type MockSystemviewConfigReader_Expecter struct {
12108+ mock *mock.Mock
12109+}
12110+
12111+func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expecter {
12112+ return &MockSystemviewConfigReader_Expecter{mock: &_m.Mock}
12113+}
12114+
12115+// Current provides a mock function for the type MockSystemviewConfigReader
12116+func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
12117+ ret := _mock.Called(ctx)
12118+
12119+ if len(ret) == 0 {
12120+ panic("no return value specified for Current")
12121+ }
12122+
12123+ var r0 port.SystemviewConfigPayload
12124+ var r1 error
12125+ if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok {
12126+ return returnFunc(ctx)
12127+ }
12128+ if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok {
12129+ r0 = returnFunc(ctx)
12130+ } else {
12131+ r0 = ret.Get(0).(port.SystemviewConfigPayload)
12132+ }
12133+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12134+ r1 = returnFunc(ctx)
12135+ } else {
12136+ r1 = ret.Error(1)
12137+ }
12138+ return r0, r1
12139+}
12140+
12141+// MockSystemviewConfigReader_Current_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Current'
12142+type MockSystemviewConfigReader_Current_Call struct {
12143+ *mock.Call
12144+}
12145+
12146+// Current is a helper method to define mock.On call
12147+// - ctx context.Context
12148+func (_e *MockSystemviewConfigReader_Expecter) Current(ctx interface{}) *MockSystemviewConfigReader_Current_Call {
12149+ return &MockSystemviewConfigReader_Current_Call{Call: _e.mock.On("Current", ctx)}
12150+}
12151+
12152+func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Current_Call {
12153+ _c.Call.Run(func(args mock.Arguments) {
12154+ var arg0 context.Context
12155+ if args[0] != nil {
12156+ arg0 = args[0].(context.Context)
12157+ }
12158+ run(
12159+ arg0,
12160+ )
12161+ })
12162+ return _c
12163+}
12164+
12165+func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {
12166+ _c.Call.Return(systemviewConfigPayload, err)
12167+ return _c
12168+}
12169+
12170+func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {
12171+ _c.Call.Return(run)
12172+ return _c
12173+}
12174+
12175+// Default provides a mock function for the type MockSystemviewConfigReader
12176+func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
12177+ ret := _mock.Called(ctx)
12178+
12179+ if len(ret) == 0 {
12180+ panic("no return value specified for Default")
12181+ }
12182+
12183+ var r0 port.SystemviewConfigPayload
12184+ var r1 error
12185+ if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok {
12186+ return returnFunc(ctx)
12187+ }
12188+ if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok {
12189+ r0 = returnFunc(ctx)
12190+ } else {
12191+ r0 = ret.Get(0).(port.SystemviewConfigPayload)
12192+ }
12193+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12194+ r1 = returnFunc(ctx)
12195+ } else {
12196+ r1 = ret.Error(1)
12197+ }
12198+ return r0, r1
12199+}
12200+
12201+// MockSystemviewConfigReader_Default_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Default'
12202+type MockSystemviewConfigReader_Default_Call struct {
12203+ *mock.Call
12204+}
12205+
12206+// Default is a helper method to define mock.On call
12207+// - ctx context.Context
12208+func (_e *MockSystemviewConfigReader_Expecter) Default(ctx interface{}) *MockSystemviewConfigReader_Default_Call {
12209+ return &MockSystemviewConfigReader_Default_Call{Call: _e.mock.On("Default", ctx)}
12210+}
12211+
12212+func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Default_Call {
12213+ _c.Call.Run(func(args mock.Arguments) {
12214+ var arg0 context.Context
12215+ if args[0] != nil {
12216+ arg0 = args[0].(context.Context)
12217+ }
12218+ run(
12219+ arg0,
12220+ )
12221+ })
12222+ return _c
12223+}
12224+
12225+func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {
12226+ _c.Call.Return(systemviewConfigPayload, err)
12227+ return _c
12228+}
12229+
12230+func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {
12231+ _c.Call.Return(run)
12232+ return _c
12233+}
12234+
12082 // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.12235 // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12083 // The first argument is typically a *testing.T value.12236 // The first argument is typically a *testing.T value.
12084 func NewMockWebUIConfigSaver(t interface {12237 func NewMockWebUIConfigSaver(t interface {
M internal/application/port/systemviews.go +1 -1
@@ -28,7 +28,7 @@ type SystemviewFavoritesService interface {
28 ListTags(ctx context.Context) ([]*entity.Tag, error)28 ListTags(ctx context.Context) ([]*entity.Tag, error)
29 SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error29 SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
30 SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error30 SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error
31- CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error)31+ CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error)
32 UpdateFolder(ctx context.Context, id int64, name, icon string) error32 UpdateFolder(ctx context.Context, id int64, name, icon string) error
33 DeleteFolder(ctx context.Context, id int64) error33 DeleteFolder(ctx context.Context, id int64) error
34 CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)34 CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
M internal/application/port/webui_config.go +1 -7
@@ -12,13 +12,7 @@ type WebUIConfig struct {
12 }12 }
13 13
14 // SystemviewCustomPerformancePayload is the editable custom performance profile shape.14 // SystemviewCustomPerformancePayload is the editable custom performance profile shape.
15-type SystemviewCustomPerformancePayload struct {15+type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig
16- SkiaCPUThreads int `json:"skia_cpu_threads"`
17- SkiaGPUThreads int `json:"skia_gpu_threads"`
18- WebProcessMemoryMB int `json:"web_process_memory_mb"`
19- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
20- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
21-}
22 16
23 // SystemviewResolvedPerformancePayload is the resolved effective performance shape.17 // SystemviewResolvedPerformancePayload is the resolved effective performance shape.
24 type SystemviewResolvedPerformancePayload struct {18 type SystemviewResolvedPerformancePayload struct {
M internal/application/usecase/manage_favorites.go +96 -21
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "net/url"
6 "sort"7 "sort"
7 "strings"8 "strings"
8 "sync"9 "sync"
@@ -69,6 +70,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
69 return existing, nil70 return existing, nil
70 }71 }
71 72
73+ return uc.addNewFavorite(ctx, input)
74+}
75+
76+func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
77+ log := logging.FromContext(ctx)
72 fav := entity.NewFavorite(input.URL, input.Title)78 fav := entity.NewFavorite(input.URL, input.Title)
73 fav.FaviconURL = input.FaviconURL79 fav.FaviconURL = input.FaviconURL
74 fav.FolderID = input.FolderID80 fav.FolderID = input.FolderID
@@ -95,8 +101,14 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
95 101
96 // AddFavorite creates a favorite from the UI-facing application port.102 // AddFavorite creates a favorite from the UI-facing application port.
97 func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {103 func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
98- if strings.TrimSpace(input.URL) == "" {104+ favoriteURL, err := normalizeFavoriteURL(input.URL)
99- return nil, fmt.Errorf("favorite URL is required")105+ if err != nil {
106+ return nil, err
107+ }
108+ if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil {
109+ return nil, err
110+ } else if existing != nil {
111+ return existing, nil
100 }112 }
101 tags := make([]entity.TagID, 0, len(input.Tags))113 tags := make([]entity.TagID, 0, len(input.Tags))
102 for _, tagID := range input.Tags {114 for _, tagID := range input.Tags {
@@ -104,15 +116,66 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.Fa
104 tags = append(tags, tagID)116 tags = append(tags, tagID)
105 }117 }
106 }118 }
107- return uc.Add(ctx, AddFavoriteInput{119+ title := strings.TrimSpace(input.Title)
108- URL: strings.TrimSpace(input.URL),120+ if title == "" {
109- Title: strings.TrimSpace(input.Title),121+ title = favoriteURL
122+ }
123+ return uc.addNewFavorite(ctx, AddFavoriteInput{
124+ URL: favoriteURL,
125+ Title: title,
110 FaviconURL: strings.TrimSpace(input.FaviconURL),126 FaviconURL: strings.TrimSpace(input.FaviconURL),
111 FolderID: input.FolderID,127 FolderID: input.FolderID,
112 Tags: tags,128 Tags: tags,
113 })129 })
114 }130 }
115 131
132+func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) {
133+ existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
134+ if err != nil {
135+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
136+ }
137+ if existing != nil {
138+ return existing, nil
139+ }
140+ rawURL = strings.TrimSpace(rawURL)
141+ if rawURL == "" || rawURL == canonicalURL {
142+ return nil, nil
143+ }
144+ existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL)
145+ if err != nil {
146+ return nil, fmt.Errorf("failed to check legacy favorite: %w", err)
147+ }
148+ return existing, nil
149+}
150+
151+func normalizeFavoriteURL(raw string) (string, error) {
152+ favoriteURL := domainurl.Normalize(strings.TrimSpace(raw))
153+ if favoriteURL == "" {
154+ return "", fmt.Errorf("favorite URL is required")
155+ }
156+ parsed, err := url.Parse(favoriteURL)
157+ if err != nil || parsed.Scheme == "" {
158+ return "", fmt.Errorf("favorite URL must be absolute")
159+ }
160+ switch strings.ToLower(parsed.Scheme) {
161+ case "http", "https":
162+ if parsed.Host == "" {
163+ return "", fmt.Errorf("favorite URL host is required")
164+ }
165+ case "dumb":
166+ if parsed.Host != "" {
167+ return favoriteURL, nil
168+ }
169+ if parsed.Opaque != "" {
170+ return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
171+ }
172+ return "", fmt.Errorf("favorite URL host is required")
173+ default:
174+ return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
175+ }
176+ return favoriteURL, nil
177+}
178+
116 // UpdateFavorite updates editable favorite metadata from the UI-facing application port.179 // UpdateFavorite updates editable favorite metadata from the UI-facing application port.
117 func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {180 func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
118 if input.ID <= 0 {181 if input.ID <= 0 {
@@ -130,7 +193,11 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port
130 return nil, fmt.Errorf("favorite %d not found", input.ID)193 return nil, fmt.Errorf("favorite %d not found", input.ID)
131 }194 }
132 195
133- fav.Title = strings.TrimSpace(input.Title)196+ title := strings.TrimSpace(input.Title)
197+ if title == "" {
198+ return nil, fmt.Errorf("favorite title is required")
199+ }
200+ fav.Title = title
134 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)201 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
135 fav.FolderID = input.FolderID202 fav.FolderID = input.FolderID
136 fav.ShortcutKey = input.ShortcutKey203 fav.ShortcutKey = input.ShortcutKey
@@ -375,18 +442,20 @@ type ToggleResult struct {
375 442
376 // Toggle adds or removes a URL from favorites based on current state.443 // Toggle adds or removes a URL from favorites based on current state.
377 // If the URL is already a favorite, it removes it. Otherwise, it adds it.444 // If the URL is already a favorite, it removes it. Otherwise, it adds it.
378-func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {445+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) {
379- log := logging.FromContext(ctx)446+ favoriteURL, err := normalizeFavoriteURL(rawURL)
380- log.Debug().Str("url", url).Msg("toggling favorite")447+ if err != nil {
381-448+ return nil, err
382- if url == "" {
383- return nil, fmt.Errorf("URL cannot be empty")
384 }449 }
450+ title = strings.TrimSpace(title)
385 451
386- // Check if already favorited452+ log := logging.FromContext(ctx)
387- existing, err := uc.favoriteRepo.FindByURL(ctx, url)453+ log.Debug().Str("url", favoriteURL).Msg("toggling favorite")
454+
455+ // Check if already favorited, including legacy rows stored before URL canonicalization.
456+ existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
388 if err != nil {457 if err != nil {
389- return nil, fmt.Errorf("failed to check existing favorite: %w", err)458+ return nil, err
390 }459 }
391 460
392 if existing != nil {461 if existing != nil {
@@ -394,26 +463,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
394 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {463 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
395 return nil, fmt.Errorf("failed to remove favorite: %w", err)464 return nil, fmt.Errorf("failed to remove favorite: %w", err)
396 }465 }
397- log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")466+ log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
398 uc.invalidateCache()467 uc.invalidateCache()
399 return &ToggleResult{468 return &ToggleResult{
400 Added: false,469 Added: false,
401- URL: url,470+ URL: favoriteURL,
402 Title: existing.Title,471 Title: existing.Title,
403 Message: "Favorite removed",472 Message: "Favorite removed",
404 }, nil473 }, nil
405 }474 }
406 475
407 // Add favorite476 // Add favorite
408- fav := entity.NewFavorite(url, title)477+ fav := entity.NewFavorite(favoriteURL, title)
409 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {478 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
410 return nil, fmt.Errorf("failed to add favorite: %w", err)479 return nil, fmt.Errorf("failed to add favorite: %w", err)
411 }480 }
412- log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")481+ log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
413 uc.invalidateCache()482 uc.invalidateCache()
414 return &ToggleResult{483 return &ToggleResult{
415 Added: true,484 Added: true,
416- URL: url,485+ URL: favoriteURL,
417 Title: title,486 Title: title,
418 Message: "Favorite added",487 Message: "Favorite added",
419 }, nil488 }, nil
@@ -509,11 +578,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite
509 }578 }
510 579
511 // CreateFolder creates a new folder.580 // CreateFolder creates a new folder.
512-func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {581+func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
582+ name = strings.TrimSpace(name)
583+ icon = strings.TrimSpace(icon)
584+ if name == "" {
585+ return nil, fmt.Errorf("folder name is required")
586+ }
513 log := logging.FromContext(ctx)587 log := logging.FromContext(ctx)
514 log.Debug().Str("name", name).Msg("creating folder")588 log.Debug().Str("name", name).Msg("creating folder")
515 589
516 folder := entity.NewFolder(name)590 folder := entity.NewFolder(name)
591+ folder.Icon = icon
517 folder.ParentID = parentID592 folder.ParentID = parentID
518 593
519 if err := uc.folderRepo.Save(ctx, folder); err != nil {594 if err := uc.folderRepo.Save(ctx, folder); err != nil {
M internal/application/usecase/manage_favorites_test.go +92 -1
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"10 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
142 result, err := uc.Toggle(ctx, "", "Title")143 result, err := uc.Toggle(ctx, "", "Title")
143 require.Error(t, err)144 require.Error(t, err)
144 assert.Nil(t, result)145 assert.Nil(t, result)
145- assert.Contains(t, err.Error(), "URL cannot be empty")146+ assert.Contains(t, err.Error(), "favorite URL is required")
147+}
148+
149+func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
150+ ctx := testContext()
151+
152+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
153+ folderRepo := repomocks.NewMockFolderRepository(t)
154+ tagRepo := repomocks.NewMockTagRepository(t)
155+
156+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
157+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
158+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
159+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
160+ })).Return(nil).Once()
161+
162+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
163+
164+ result, err := uc.Toggle(ctx, " example.com ", " Example ")
165+ require.NoError(t, err)
166+ require.NotNil(t, result)
167+ assert.True(t, result.Added)
168+ assert.Equal(t, "https://example.com", result.URL)
169+ assert.Equal(t, "Example", result.Title)
146 }170 }
147 171
148 func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {172 func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
@@ -342,3 +366,70 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin
342 require.Len(t, favoritesAfter, 1)366 require.Len(t, favoritesAfter, 1)
343 assert.Nil(t, favoritesAfter[0].FolderID)367 assert.Nil(t, favoritesAfter[0].FolderID)
344 }368 }
369+
370+func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
371+ ctx := testContext()
372+
373+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
374+ folderRepo := repomocks.NewMockFolderRepository(t)
375+ tagRepo := repomocks.NewMockTagRepository(t)
376+
377+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
378+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
379+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
380+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
381+ })).Return(nil).Once()
382+
383+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
384+
385+ fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386+ require.NoError(t, err)
387+ require.NotNil(t, fav)
388+ assert.Equal(t, "https://example.com", fav.URL)
389+ assert.Equal(t, "Example", fav.Title)
390+}
391+
392+func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
393+ ctx := testContext()
394+
395+ tests := []string{
396+ "",
397+ "not a url",
398+ "javascript:alert(1)",
399+ "data:text/html,<script>alert(1)</script>",
400+ "https:///missing-host",
401+ "dumb:///missing-host",
402+ }
403+ for _, raw := range tests {
404+ t.Run(raw, func(t *testing.T) {
405+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
406+ folderRepo := repomocks.NewMockFolderRepository(t)
407+ tagRepo := repomocks.NewMockTagRepository(t)
408+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
409+
410+ _, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: raw})
411+ require.Error(t, err)
412+ })
413+ }
414+}
415+
416+func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) {
417+ ctx := testContext()
418+
419+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
420+ folderRepo := repomocks.NewMockFolderRepository(t)
421+ tagRepo := repomocks.NewMockTagRepository(t)
422+
423+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()
424+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once()
425+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
426+ return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"
427+ })).Return(nil).Once()
428+
429+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
430+
431+ fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
432+ require.NoError(t, err)
433+ require.NotNil(t, fav)
434+ assert.Equal(t, "dumb://history", fav.URL)
435+}
M internal/application/usecase/read_systemview_config.go +9 -3
@@ -2,7 +2,7 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5- "fmt"5+ "errors"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 )8 )
@@ -14,13 +14,19 @@ type ReadSystemviewConfigUseCase struct {
14 14
15 // NewReadSystemviewConfigUseCase creates a new read systemview config use case.15 // NewReadSystemviewConfigUseCase creates a new read systemview config use case.
16 func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase {16 func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase {
17+ if reader == nil {
18+ panic("NewReadSystemviewConfigUseCase: reader is nil")
19+ }
17 return &ReadSystemviewConfigUseCase{reader: reader}20 return &ReadSystemviewConfigUseCase{reader: reader}
18 }21 }
19 22
23+// ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.
24+var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")
25+
20 // Current returns the current systemview config payload.26 // Current returns the current systemview config payload.
21 func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {27 func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
22 if uc == nil || uc.reader == nil {28 if uc == nil || uc.reader == nil {
23- return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil")29+ return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
24 }30 }
25 return uc.reader.Current(ctx)31 return uc.reader.Current(ctx)
26 }32 }
@@ -28,7 +34,7 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.System
28 // Default returns the default systemview config payload.34 // Default returns the default systemview config payload.
29 func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {35 func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
30 if uc == nil || uc.reader == nil {36 if uc == nil || uc.reader == nil {
31- return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil")37+ return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
32 }38 }
33 return uc.reader.Default(ctx)39 return uc.reader.Default(ctx)
34 }40 }
M internal/application/usecase/read_systemview_config_test.go +21 -22
@@ -5,48 +5,47 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/mock"
8 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
9 )11 )
10 12
11 func TestReadSystemviewConfigUseCase_Current(t *testing.T) {13 func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
12- reader := &stubSystemviewConfigReader{14+ reader := portmocks.NewMockSystemviewConfigReader(t)
13- current: port.SystemviewConfigPayload{EngineType: "webkit"},15+ want := port.SystemviewConfigPayload{EngineType: "webkit"}
14- }16+ reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()
15 17
16 uc := NewReadSystemviewConfigUseCase(reader)18 uc := NewReadSystemviewConfigUseCase(reader)
17 19
18 got, err := uc.Current(context.Background())20 got, err := uc.Current(context.Background())
19 require.NoError(t, err)21 require.NoError(t, err)
20- require.Equal(t, reader.current, got)22+ require.Equal(t, want, got)
21- require.Equal(t, []string{"current"}, reader.calls)
22 }23 }
23 24
24 func TestReadSystemviewConfigUseCase_Default(t *testing.T) {25 func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
25- reader := &stubSystemviewConfigReader{26+ reader := portmocks.NewMockSystemviewConfigReader(t)
26- defaultPayload: port.SystemviewConfigPayload{EngineType: "webkit"},27+ want := port.SystemviewConfigPayload{EngineType: "webkit"}
27- }28+ reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()
28 29
29 uc := NewReadSystemviewConfigUseCase(reader)30 uc := NewReadSystemviewConfigUseCase(reader)
30 31
31 got, err := uc.Default(context.Background())32 got, err := uc.Default(context.Background())
32 require.NoError(t, err)33 require.NoError(t, err)
33- require.Equal(t, reader.defaultPayload, got)34+ require.Equal(t, want, got)
34- require.Equal(t, []string{"default"}, reader.calls)
35 }35 }
36 36
37-// Handwritten fake to capture stateful config reads for assertions.37+func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) {
38-type stubSystemviewConfigReader struct {38+ require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() {
39- current port.SystemviewConfigPayload39+ NewReadSystemviewConfigUseCase(nil)
40- defaultPayload port.SystemviewConfigPayload40+ })
41- calls []string
42 }41 }
43 42
44-func (r *stubSystemviewConfigReader) Current(context.Context) (port.SystemviewConfigPayload, error) {43+func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) {
45- r.calls = append(r.calls, "current")44+ var uc *ReadSystemviewConfigUseCase
46- return r.current, nil45+
47-}46+ _, err := uc.Current(context.Background())
47+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
48 48
49-func (r *stubSystemviewConfigReader) Default(context.Context) (port.SystemviewConfigPayload, error) {49+ _, err = uc.Default(context.Background())
50- r.calls = append(r.calls, "default")50+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
51- return r.defaultPayload, nil
52 }51 }
M internal/application/usecase/save_webui_config.go +3 -2
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -55,8 +56,8 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
55 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {56 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
56 errs = append(errs, "appearance.default_font_size must be between 1 and 72")57 errs = append(errs, "appearance.default_font_size must be between 1 and 72")
57 }58 }
58- if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {59+ if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
59- errs = append(errs, "default_ui_scale must be between 0.5 and 3.0")60+ errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
60 }61 }
61 62
62 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)63 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
M internal/application/usecase/save_webui_config_test.go +28 -22
@@ -2,10 +2,13 @@ package usecase_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "math"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
8 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
10 )13 )
11 14
@@ -36,13 +39,28 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
36 },39 },
37 wantErr: "performance.profile",40 wantErr: "performance.profile",
38 },41 },
42+ {
43+ name: "rejects NaN UI scale",
44+ mutate: func(cfg *port.WebUIConfig) {
45+ cfg.DefaultUIScale = math.NaN()
46+ },
47+ wantErr: "default_ui_scale must be a finite value",
48+ },
49+ {
50+ name: "rejects infinite UI scale",
51+ mutate: func(cfg *port.WebUIConfig) {
52+ cfg.DefaultUIScale = math.Inf(1)
53+ },
54+ wantErr: "default_ui_scale must be a finite value",
55+ },
39 }56 }
40 57
41 for _, tt := range tests {58 for _, tt := range tests {
42 t.Run(tt.name, func(t *testing.T) {59 t.Run(tt.name, func(t *testing.T) {
43 cfg := validWebUIConfig()60 cfg := validWebUIConfig()
44 tt.mutate(&cfg)61 tt.mutate(&cfg)
45- uc := usecase.NewSaveWebUIConfigUseCase(fakeWebUIConfigSaver{})62+ saver := portmocks.NewMockWebUIConfigSaver(t)
63+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
46 64
47 err := uc.Execute(context.Background(), cfg)65 err := uc.Execute(context.Background(), cfg)
48 66
@@ -53,7 +71,11 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
53 }71 }
54 72
55 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {73 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
56- saver := &capturingWebUIConfigSaver{}74+ saver := portmocks.NewMockWebUIConfigSaver(t)
75+ var saved port.WebUIConfig
76+ saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("port.WebUIConfig")).Run(func(_ context.Context, cfg port.WebUIConfig) {
77+ saved = cfg
78+ }).Return(nil).Once()
57 uc := usecase.NewSaveWebUIConfigUseCase(saver)79 uc := usecase.NewSaveWebUIConfigUseCase(saver)
58 cfg := validWebUIConfig()80 cfg := validWebUIConfig()
59 cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}81 cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
@@ -61,9 +83,10 @@ func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
61 err := uc.Execute(context.Background(), cfg)83 err := uc.Execute(context.Background(), cfg)
62 84
63 require.NoError(t, err)85 require.NoError(t, err)
64- require.Contains(t, saver.saved.SearchShortcuts, "ddg")86+ require.NotContains(t, saved.SearchShortcuts, " ddg ")
65- require.Equal(t, "https://duckduckgo.com/?q=%s", saver.saved.SearchShortcuts["ddg"].URL)87+ require.Contains(t, saved.SearchShortcuts, "ddg")
66- require.Equal(t, "DuckDuckGo", saver.saved.SearchShortcuts["ddg"].Description)88+ require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)
89+ require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
67 }90 }
68 91
69 func validWebUIConfig() port.WebUIConfig {92 func validWebUIConfig() port.WebUIConfig {
@@ -94,20 +117,3 @@ func validWebUIConfig() port.WebUIConfig {
94 SearchShortcuts: map[string]port.SearchShortcut{},117 SearchShortcuts: map[string]port.SearchShortcut{},
95 }118 }
96 }119 }
97-
98-// Handwritten fake to preserve stateful save assertions without mock generation.
99-type fakeWebUIConfigSaver struct{}
100-
101-func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error {
102- return nil
103-}
104-
105-// Handwritten fake to capture the saved config state for assertions.
106-type capturingWebUIConfigSaver struct {
107- saved port.WebUIConfig
108-}
109-
110-func (s *capturingWebUIConfigSaver) SaveWebUIConfig(_ context.Context, cfg port.WebUIConfig) error {
111- s.saved = cfg
112- return nil
113-}
M internal/application/usecase/search_history.go +24 -8
@@ -81,9 +81,9 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st
81 if limit <= 0 {81 if limit <= 0 {
82 limit = 5082 limit = 50
83 }83 }
84- domain = domainurl.CanonicalDomain(domain)84+ domain, err := canonicalHistoryDomain(domain)
85- if domain == "" {85+ if err != nil {
86- return nil, fmt.Errorf("domain is required")86+ return nil, err
87 }87 }
88 88
89 entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)89 entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
@@ -93,6 +93,16 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st
93 return entries, nil93 return entries, nil
94 }94 }
95 95
96+// canonicalHistoryDomain normalizes a domain and rejects empty values before
97+// the domain reaches exact-match persistence queries.
98+func canonicalHistoryDomain(domain string) (string, error) {
99+ domain = domainurl.CanonicalDomain(domain)
100+ if domain == "" {
101+ return "", fmt.Errorf("domain is required")
102+ }
103+ return domain, nil
104+}
105+
96 // GetRecentSince retrieves history entries visited within the last N days.106 // GetRecentSince retrieves history entries visited within the last N days.
97 // If days is 0, returns all history entries.107 // If days is 0, returns all history entries.
98 // If days is negative, defaults to 30 days.108 // If days is negative, defaults to 30 days.
@@ -173,7 +183,7 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
173 return nil183 return nil
174 }184 }
175 185
176-// ClearRange deletes history entries for a named range.186+// ClearRange deletes history entries inside a named recent range.
177 func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {187 func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
178 cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())188 cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
179 if !ok {189 if !ok {
@@ -183,7 +193,13 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string)
183 return uc.ClearAll(ctx)193 return uc.ClearAll(ctx)
184 }194 }
185 195
186- return uc.ClearOlderThan(ctx, cutoff)196+ log := logging.FromContext(ctx)
197+ log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
198+ if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
199+ return fmt.Errorf("failed to clear history range: %w", err)
200+ }
201+ log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
202+ return nil
187 }203 }
188 204
189 // ClearAll deletes all history entries.205 // ClearAll deletes all history entries.
@@ -214,9 +230,9 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
214 230
215 // DeleteByDomain removes all history entries for a domain.231 // DeleteByDomain removes all history entries for a domain.
216 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {232 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
217- domain = domainurl.CanonicalDomain(domain)233+ domain, err := canonicalHistoryDomain(domain)
218- if domain == "" {234+ if err != nil {
219- return fmt.Errorf("domain is required")235+ return err
220 }236 }
221 237
222 log := logging.FromContext(ctx)238 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +57 -0
@@ -152,6 +152,39 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
152 assert.Empty(t, result)152 assert.Empty(t, result)
153 }153 }
154 154
155+func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {
156+ ctx := testContext()
157+ historyRepo := repomocks.NewMockHistoryRepository(t)
158+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
159+ return !since.IsZero() && time.Since(since) <= time.Hour+time.Minute
160+ })).Return(nil).Once()
161+
162+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
163+
164+ err := uc.ClearRange(ctx, "hour")
165+ require.NoError(t, err)
166+}
167+
168+func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
169+ ctx := testContext()
170+ historyRepo := repomocks.NewMockHistoryRepository(t)
171+ today := time.Now()
172+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
173+ return since.Year() == today.Year() &&
174+ since.Month() == today.Month() &&
175+ since.Day() == today.Day() &&
176+ since.Hour() == 0 &&
177+ since.Minute() == 0 &&
178+ since.Second() == 0 &&
179+ since.Nanosecond() == 0
180+ })).Return(nil).Once()
181+
182+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
183+
184+ err := uc.ClearRange(ctx, "day")
185+ require.NoError(t, err)
186+}
187+
155 func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {188 func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
156 ctx := testContext()189 ctx := testContext()
157 190
@@ -176,3 +209,27 @@ func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T)
176 require.Error(t, err)209 require.Error(t, err)
177 assert.Contains(t, err.Error(), "unknown history delete range")210 assert.Contains(t, err.Error(), "unknown history delete range")
178 }211 }
212+
213+func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
214+ ctx := testContext()
215+ historyRepo := repomocks.NewMockHistoryRepository(t)
216+ want := []*entity.HistoryEntry{{URL: "https://example_.com"}}
217+ historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once()
218+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
219+
220+ result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0)
221+
222+ require.NoError(t, err)
223+ assert.Equal(t, want, result)
224+}
225+
226+func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) {
227+ ctx := testContext()
228+ historyRepo := repomocks.NewMockHistoryRepository(t)
229+ historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once()
230+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
231+
232+ err := uc.DeleteByDomain(ctx, "example_.com")
233+
234+ require.NoError(t, err)
235+}
M internal/domain/history/delete_range.go +1 -1
@@ -10,7 +10,7 @@ func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) {
10 case "hour":10 case "hour":
11 return now.Add(-time.Hour), false, true11 return now.Add(-time.Hour), false, true
12 case "day":12 case "day":
13- return now.AddDate(0, 0, -1), false, true13+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), false, true
14 case "week":14 case "week":
15 return now.AddDate(0, 0, -7), false, true15 return now.AddDate(0, 0, -7), false, true
16 case "month":16 case "month":
M internal/domain/history/delete_range_test.go +1 -1
@@ -16,7 +16,7 @@ func TestDeleteRangeCutoff(t *testing.T) {
16 wantOK bool16 wantOK bool
17 }{17 }{
18 {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true},18 {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true},
19- {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true},19+ {name: "day", rangeID: "day", want: time.Date(2026, time.April, 11, 0, 0, 0, 0, time.UTC), wantAll: false, wantOK: true},
20 {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},20 {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},
21 {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},21 {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},
22 {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true},22 {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true},
M internal/domain/repository/history.go +3 -0
@@ -47,6 +47,9 @@ type HistoryRepository interface {
47 // DeleteOlderThan removes entries older than the given time.47 // DeleteOlderThan removes entries older than the given time.
48 DeleteOlderThan(ctx context.Context, before time.Time) error48 DeleteOlderThan(ctx context.Context, before time.Time) error
49 49
50+ // DeleteSince removes entries visited at or after the given time.
51+ DeleteSince(ctx context.Context, since time.Time) error
52+
50 // DeleteAll removes all history entries.53 // DeleteAll removes all history entries.
51 DeleteAll(ctx context.Context) error54 DeleteAll(ctx context.Context) error
52 55
M internal/domain/repository/mocks/mocks.go +57 -0
@@ -2129,6 +2129,63 @@ func (_c *MockHistoryRepository_DeleteOlderThan_Call) RunAndReturn(run func(ctx
2129 return _c2129 return _c
2130 }2130 }
2131 2131
2132+// DeleteSince provides a mock function for the type MockHistoryRepository
2133+func (_mock *MockHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error {
2134+ ret := _mock.Called(ctx, since)
2135+
2136+ if len(ret) == 0 {
2137+ panic("no return value specified for DeleteSince")
2138+ }
2139+
2140+ var r0 error
2141+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {
2142+ r0 = returnFunc(ctx, since)
2143+ } else {
2144+ r0 = ret.Error(0)
2145+ }
2146+ return r0
2147+}
2148+
2149+// MockHistoryRepository_DeleteSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSince'
2150+type MockHistoryRepository_DeleteSince_Call struct {
2151+ *mock.Call
2152+}
2153+
2154+// DeleteSince is a helper method to define mock.On call
2155+// - ctx context.Context
2156+// - since time.Time
2157+func (_e *MockHistoryRepository_Expecter) DeleteSince(ctx interface{}, since interface{}) *MockHistoryRepository_DeleteSince_Call {
2158+ return &MockHistoryRepository_DeleteSince_Call{Call: _e.mock.On("DeleteSince", ctx, since)}
2159+}
2160+
2161+func (_c *MockHistoryRepository_DeleteSince_Call) Run(run func(ctx context.Context, since time.Time)) *MockHistoryRepository_DeleteSince_Call {
2162+ _c.Call.Run(func(args mock.Arguments) {
2163+ var arg0 context.Context
2164+ if args[0] != nil {
2165+ arg0 = args[0].(context.Context)
2166+ }
2167+ var arg1 time.Time
2168+ if args[1] != nil {
2169+ arg1 = args[1].(time.Time)
2170+ }
2171+ run(
2172+ arg0,
2173+ arg1,
2174+ )
2175+ })
2176+ return _c
2177+}
2178+
2179+func (_c *MockHistoryRepository_DeleteSince_Call) Return(err error) *MockHistoryRepository_DeleteSince_Call {
2180+ _c.Call.Return(err)
2181+ return _c
2182+}
2183+
2184+func (_c *MockHistoryRepository_DeleteSince_Call) RunAndReturn(run func(ctx context.Context, since time.Time) error) *MockHistoryRepository_DeleteSince_Call {
2185+ _c.Call.Return(run)
2186+ return _c
2187+}
2188+
2132 // FindByURL provides a mock function for the type MockHistoryRepository2189 // FindByURL provides a mock function for the type MockHistoryRepository
2133 func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {2190 func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
2134 ret := _mock.Called(ctx, url)2191 ret := _mock.Called(ctx, url)
M internal/domain/url/normalize.go +2 -4
@@ -192,10 +192,8 @@ func CanonicalDomain(raw string) string {
192 if domain := ExtractDomain(raw); domain != "" {192 if domain := ExtractDomain(raw); domain != "" {
193 return domain193 return domain
194 }194 }
195- for _, sep := range []string{"/", "?", "#"} {195+ if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
196- if before, _, ok := strings.Cut(raw, sep); ok {196+ raw = raw[:idx]
197- raw = before
198- }
199 }197 }
200 return strings.TrimPrefix(raw, "www.")198 return strings.TrimPrefix(raw, "www.")
201 }199 }
M internal/domain/url/normalize_test.go +28 -2
@@ -699,8 +699,12 @@ func TestCanonicalDomain(t *testing.T) {
699 input string699 input string
700 want string700 want string
701 }{701 }{
702+ {name: "empty", input: "", want: ""},
703+ {name: "whitespace only", input: " ", want: ""},
702 {name: "url", input: "https://www.example.com/path", want: "example.com"},704 {name: "url", input: "https://www.example.com/path", want: "example.com"},
703 {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},705 {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
706+ {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
707+ {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"},
704 {name: "domain", input: "www.example.com", want: "example.com"},708 {name: "domain", input: "www.example.com", want: "example.com"},
705 {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},709 {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
706 }710 }
@@ -715,7 +719,29 @@ func TestCanonicalDomain(t *testing.T) {
715 }719 }
716 720
717 func TestDisplayDomain(t *testing.T) {721 func TestDisplayDomain(t *testing.T) {
718- if got := DisplayDomain("https://www.example.com:8443/path"); got != "example.com" {722+ tests := []struct {
719- t.Errorf("DisplayDomain() = %q, want %q", got, "example.com")723+ name string
724+ input string
725+ want string
726+ }{
727+ {name: "empty", input: "", want: ""},
728+ {name: "whitespace only", input: " ", want: ""},
729+ {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"},
730+ {name: "url without port", input: "https://www.example.com/path", want: "example.com"},
731+ {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
732+ {name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"},
733+ {name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"},
734+ {name: "invalid url", input: "%zz", want: "%zz"},
735+ {name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"},
736+ {name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"},
737+ {name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"},
738+ }
739+
740+ for _, tt := range tests {
741+ t.Run(tt.name, func(t *testing.T) {
742+ if got := DisplayDomain(tt.input); got != tt.want {
743+ t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want)
744+ }
745+ })
720 }746 }
721 }747 }
M internal/infrastructure/cef/scheme_handler.go +101 -7
@@ -1,24 +1,29 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "embed"6 "embed"
6 "encoding/base64"7 "encoding/base64"
7 "encoding/json"8 "encoding/json"
8 "fmt"9 "fmt"
9 "html"10 "html"
11+ "io"
10 "io/fs"12 "io/fs"
13+ "net"
11 "net/http"14 "net/http"
12 "net/url"15 "net/url"
13- "path/filepath"16+ "path"
14 "strings"17 "strings"
15 "sync"18 "sync"
16 "time"19 "time"
17 "unsafe"20 "unsafe"
18 21
22+ "github.com/andybalholm/brotli"
19 purecef "github.com/bnema/purego-cef/cef"23 purecef "github.com/bnema/purego-cef/cef"
20 24
21 "github.com/bnema/dumber/internal/application/port"25 "github.com/bnema/dumber/internal/application/port"
26+ "github.com/bnema/dumber/internal/infrastructure/netguard"
22 "github.com/bnema/dumber/internal/infrastructure/webutil"27 "github.com/bnema/dumber/internal/infrastructure/webutil"
23 "github.com/bnema/dumber/internal/logging"28 "github.com/bnema/dumber/internal/logging"
24 "github.com/rs/zerolog"29 "github.com/rs/zerolog"
@@ -32,6 +37,8 @@ const (
32 errorPath = "error"37 errorPath = "error"
33 indexHTML = "index.html"38 indexHTML = "index.html"
34 maxSchemeTruncatedURLLength = 24039 maxSchemeTruncatedURLLength = 240
40+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
41+ systemviewsAssetDir = "systemviews"
35 )42 )
36 43
37 // pageRootFiles maps internal page hosts/paths to their HTML entry points.44 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
@@ -100,7 +107,7 @@ func newDumbSchemeHandler(
100 ctx: ctx,107 ctx: ctx,
101 messageRouter: router,108 messageRouter: router,
102 transcoder: transcoder,109 transcoder: transcoder,
103- assetDir: "systemviews",110+ assetDir: systemviewsAssetDir,
104 logger: log.With().Str("component", "scheme-handler").Logger(),111 logger: log.With().Str("component", "scheme-handler").Logger(),
105 currentConfigPayload: currentConfigPayload,112 currentConfigPayload: currentConfigPayload,
106 defaultConfigPayload: defaultConfigPayload,113 defaultConfigPayload: defaultConfigPayload,
@@ -190,8 +197,14 @@ func isAPIPostMethod(method string) bool {
190 func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {197 func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {
191 switch path {198 switch path {
192 case "/api/config":199 case "/api/config":
200+ if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
201+ return denied, true
202+ }
193 return h.handleConfigAPI(h.currentConfigPayload), true203 return h.handleConfigAPI(h.currentConfigPayload), true
194 case "/api/config/default":204 case "/api/config/default":
205+ if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
206+ return denied, true
207+ }
195 return h.handleConfigAPI(h.defaultConfigPayload), true208 return h.handleConfigAPI(h.defaultConfigPayload), true
196 case "/api/transcode":209 case "/api/transcode":
197 return h.handleTranscodeAPI(request), true210 return h.handleTranscodeAPI(request), true
@@ -224,6 +237,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
224 }237 }
225 238
226 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {239 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
240+ if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
241+ return denied
242+ }
227 body := readBodyFromHeader(request)243 body := readBodyFromHeader(request)
228 if body == nil {244 if body == nil {
229 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})245 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
@@ -235,6 +251,14 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re
235 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)251 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
236 }252 }
237 253
254+func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler {
255+ origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
256+ if origin == "" || strings.EqualFold(origin, actualInternalOrigin) {
257+ return nil
258+ }
259+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
260+}
261+
238 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {262 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
239 if h.transcoder == nil {263 if h.transcoder == nil {
240 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})264 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
@@ -255,6 +279,9 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
255 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {279 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
256 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})280 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
257 }281 }
282+ if err := validateTranscodeSourceURL(h.ctx, sourceParsed); err != nil {
283+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": err.Error()})
284+ }
258 285
259 headers := make(map[string]string)286 headers := make(map[string]string)
260 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {287 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
@@ -287,6 +314,39 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
287 })314 })
288 }315 }
289 316
317+func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error {
318+ if source == nil || source.Host == "" {
319+ return fmt.Errorf("invalid src")
320+ }
321+ host := source.Hostname()
322+ if host == "" {
323+ return fmt.Errorf("invalid src host")
324+ }
325+ if ip := net.ParseIP(host); ip != nil {
326+ if isBlockedTranscodeIP(ip) {
327+ return fmt.Errorf("private src host not allowed")
328+ }
329+ return nil
330+ }
331+
332+ lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
333+ defer cancel()
334+ addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
335+ if err != nil || len(addrs) == 0 {
336+ return fmt.Errorf("src host could not be resolved")
337+ }
338+ for _, addr := range addrs {
339+ if isBlockedTranscodeIP(addr.IP) {
340+ return fmt.Errorf("private src host not allowed")
341+ }
342+ }
343+ return nil
344+}
345+
346+func isBlockedTranscodeIP(ip net.IP) bool {
347+ return netguard.IsBlockedTranscodeIP(ip)
348+}
349+
290 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {350 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
291 if build == nil {351 if build == nil {
292 return nil, fmt.Errorf("config payload builder not configured")352 return nil, fmt.Errorf("config payload builder not configured")
@@ -499,7 +559,11 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
499 assetDir = h.assetDir559 assetDir = h.assetDir
500 }560 }
501 561
502- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))562+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
563+ if !ok {
564+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
565+ }
566+
503 data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)567 data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
504 if err != nil {568 if err != nil {
505 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")569 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
@@ -516,10 +580,40 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
516 return newStaticResourceHandler(http.StatusOK, contentType, data, headers)580 return newStaticResourceHandler(http.StatusOK, contentType, data, headers)
517 }581 }
518 582
583+func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
584+ assetDir = strings.Trim(assetDir, "/")
585+ if assetDir != systemviewsAssetDir {
586+ return "", "", false
587+ }
588+
589+ relPath = strings.TrimLeft(relPath, "/")
590+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
591+ return "", "", false
592+ }
593+
594+ cleanRelPath = path.Clean(relPath)
595+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
596+ return "", "", false
597+ }
598+
599+ fullPath = path.Join(assetDir, cleanRelPath)
600+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
601+ return "", "", false
602+ }
603+ return fullPath, cleanRelPath, true
604+}
605+
519 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {606 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
520 if strings.HasSuffix(relPath, ".wasm") {607 if strings.HasSuffix(relPath, ".wasm") {
521- if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {608+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
522- return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil609+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
610+ if err != nil {
611+ return nil, nil, err
612+ }
613+ if len(data) > maxSystemviewsWASMBytes {
614+ return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
615+ }
616+ return data, nil, nil
523 }617 }
524 }618 }
525 data, err := fs.ReadFile(assets, fullPath)619 data, err := fs.ReadFile(assets, fullPath)
@@ -591,7 +685,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
591 }685 }
592 686
593 if !strings.Contains(path, "/") && strings.Contains(path, ".") {687 if !strings.Contains(path, "/") && strings.Contains(path, ".") {
594- return "systemviews", path, true688+ return systemviewsAssetDir, path, true
595 }689 }
596 690
597 return "", "", false691 return "", "", false
@@ -600,7 +694,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
600 func assetDirForPageHost(host string) string {694 func assetDirForPageHost(host string) string {
601 switch host {695 switch host {
602 case historyPath, favoritesPath, configPath, errorPath:696 case historyPath, favoritesPath, configPath, errorPath:
603- return "systemviews"697+ return systemviewsAssetDir
604 default:698 default:
605 return ""699 return ""
606 }700 }
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +49 -0
@@ -3,6 +3,8 @@ package cef
3 import (3 import (
4 "net/url"4 "net/url"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/dumber/assets"
6 )8 )
7 9
8 func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {10 func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
@@ -40,6 +42,21 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
40 }42 }
41 }43 }
42 44
45+func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
46+ t.Parallel()
47+
48+ data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
49+ if err != nil {
50+ t.Fatalf("readAssetWithEncoding() error = %v", err)
51+ }
52+ if len(data) < 4 || string(data[:4]) != "\x00asm" {
53+ t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
54+ }
55+ if _, ok := headers["Content-Encoding"]; ok {
56+ t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"])
57+ }
58+}
59+
43 func mustParseURL(t *testing.T, raw string) *url.URL {60 func mustParseURL(t *testing.T, raw string) *url.URL {
44 t.Helper()61 t.Helper()
45 u, err := url.Parse(raw)62 u, err := url.Parse(raw)
@@ -48,3 +65,35 @@ func mustParseURL(t *testing.T, raw string) *url.URL {
48 }65 }
49 return u66 return u
50 }67 }
68+
69+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
70+ t.Parallel()
71+
72+ fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
73+ if !ok {
74+ t.Fatal("safeSystemviewsAssetPath() rejected safe cleaned path")
75+ }
76+ if fullPath != "systemviews/systemviews.css" || relPath != "systemviews.css" {
77+ t.Fatalf("safeSystemviewsAssetPath() = (%q, %q), want (systemviews/systemviews.css, systemviews.css)", fullPath, relPath)
78+ }
79+
80+ invalid := []struct {
81+ name string
82+ assetDir string
83+ relPath string
84+ }{
85+ {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
86+ {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
87+ {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
88+ {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
89+ {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
90+ }
91+ for _, tt := range invalid {
92+ t.Run(tt.name, func(t *testing.T) {
93+ t.Parallel()
94+ if fullPath, relPath, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath); ok {
95+ t.Fatalf("safeSystemviewsAssetPath(%q, %q) = (%q, %q, true), want rejected", tt.assetDir, tt.relPath, fullPath, relPath)
96+ }
97+ })
98+ }
99+}
M internal/infrastructure/cef/scheme_handler_test.go +53 -0
@@ -4,7 +4,9 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "errors"6 "errors"
7+ "net"
7 "net/http"8 "net/http"
9+ "net/url"
8 "testing"10 "testing"
9 11
10 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
@@ -60,6 +62,57 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
60 require.Contains(t, err.Error(), "default config payload builder")62 require.Contains(t, err.Error(), "default config payload builder")
61 }63 }
62 64
65+func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) {
66+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
67+ require.NoError(t, err)
68+
69+ request := cefmocks.NewMockRequest(t)
70+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
71+
72+ require.Nil(t, h.rejectForbiddenAPIOrigin(request))
73+}
74+
75+func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) {
76+ oldNewResourceHandler := cefNewResourceHandler
77+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
78+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
79+
80+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
81+ require.NoError(t, err)
82+
83+ request := cefmocks.NewMockRequest(t)
84+ request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
85+
86+ response := cefmocks.NewMockResponse(t)
87+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
88+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
89+ response.EXPECT().SetMimeType("application/json").Once()
90+ expectAPIResponseHeaders(response)
91+
92+ handler := h.rejectForbiddenAPIOrigin(request)
93+ require.NotNil(t, handler)
94+ var responseLength int64
95+ handler.GetResponseHeaders(response, &responseLength, 0)
96+ require.Positive(t, responseLength)
97+}
98+
99+func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) {
100+ privateURL, err := url.Parse("http://127.0.0.1/video.mp4")
101+ require.NoError(t, err)
102+
103+ err = validateTranscodeSourceURL(context.Background(), privateURL)
104+ require.Error(t, err)
105+ require.Contains(t, err.Error(), "private src host")
106+}
107+
108+func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
109+ for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} {
110+ t.Run(rawIP, func(t *testing.T) {
111+ require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP)))
112+ })
113+ }
114+}
115+
63 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {116 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
64 request := cefmocks.NewMockRequest(t)117 request := cefmocks.NewMockRequest(t)
65 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))118 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
M internal/infrastructure/config/defaults.go +11 -11
@@ -238,17 +238,17 @@ func DefaultConfig() *Config {
238 },238 },
239 Shortcuts: GlobalShortcutsConfig{239 Shortcuts: GlobalShortcutsConfig{
240 Actions: map[string]ActionBinding{240 Actions: map[string]ActionBinding{
241- "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},241+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
242- "toggle_history_systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},242+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
243- "toggle_favorites_systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},243+ "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},
244- "toggle_config_systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},244+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
245- "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},245+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
246- "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},246+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
247- "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},247+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
248- "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},248+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
249- "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},249+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
250- "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},250+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
251- "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},251+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
252 },252 },
253 },253 },
254 FloatingPane: FloatingPaneConfig{254 FloatingPane: FloatingPaneConfig{
M internal/infrastructure/config/defaults_test.go +3 -3
@@ -28,9 +28,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
28 assert.True(t, cfg.Engine.WebKit.ITPEnabled)28 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
29 29
30 // System view shortcuts are first-class global actions.30 // System view shortcuts are first-class global actions.
31- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_history_systemview", []string{"ctrl+h"})31+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
32- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_favorites_systemview", []string{})32+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
33- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_config_systemview", []string{})33+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
34 34
35 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.35 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
36 // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above).36 // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above).
M internal/infrastructure/config/floating_pane_config_test.go +2 -2
@@ -15,7 +15,7 @@ func TestFloatingPaneConfig_Defaults(t *testing.T) {
15 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)15 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
16 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)16 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
17 17
18- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
19 require.True(t, ok)19 require.True(t, ok)
20 assert.Equal(t, []string{"alt+f"}, binding.Keys)20 assert.Equal(t, []string{"alt+f"}, binding.Keys)
21 }21 }
@@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
31 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)31 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
32 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)32 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
33 33
34- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
35 require.True(t, ok)35 require.True(t, ok)
36 assert.Equal(t, []string{"alt+f"}, binding.Keys)36 assert.Equal(t, []string{"alt+f"}, binding.Keys)
37 }37 }
M internal/infrastructure/config/migrate_test.go +4 -4
@@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
695 for actionName, value := range defaultActions {695 for actionName, value := range defaultActions {
696 userActions[actionName] = value696 userActions[actionName] = value
697 }697 }
698- delete(userActions, "toggle_floating_pane")698+ delete(userActions, "toggle-floating-pane")
699 699
700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701 "workspace.shortcuts.actions": userActions,701 "workspace.shortcuts.actions": userActions,
702 })702 })
703 703
704- assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)
705 }705 }
706 706
707 func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {707 func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
@@ -750,7 +750,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
750 actions, ok := actionsAny.(map[string]any)750 actions, ok := actionsAny.(map[string]any)
751 require.True(t, ok)751 require.True(t, ok)
752 752
753- toggleAny, hasToggle := actions["toggle_floating_pane"]753+ toggleAny, hasToggle := actions["toggle-floating-pane"]
754 assert.True(t, hasToggle)754 assert.True(t, hasToggle)
755 assert.NotNil(t, toggleAny)755 assert.NotNil(t, toggleAny)
756 756
@@ -764,7 +764,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
764 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {764 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765 m := NewMigrator()765 m := NewMigrator()
766 766
767- value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")767+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
768 768
769 action, ok := value.(ActionBinding)769 action, ok := value.(ActionBinding)
770 require.True(t, ok)770 require.True(t, ok)
M internal/infrastructure/config/webui_payload.go +22 -18
@@ -45,31 +45,35 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste
45 }45 }
46 46
47 func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig {47 func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig {
48+ defaults := DefaultConfig().Appearance
48 return port.WebUIAppearanceConfig{49 return port.WebUIAppearanceConfig{
49 SansFont: appearance.SansFont,50 SansFont: appearance.SansFont,
50 SerifFont: appearance.SerifFont,51 SerifFont: appearance.SerifFont,
51 MonospaceFont: appearance.MonospaceFont,52 MonospaceFont: appearance.MonospaceFont,
52 DefaultFontSize: appearance.DefaultFontSize,53 DefaultFontSize: appearance.DefaultFontSize,
53 ColorScheme: appearance.ColorScheme,54 ColorScheme: appearance.ColorScheme,
54- LightPalette: port.ColorPalette{55+ LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),
55- Background: appearance.LightPalette.Background,56+ DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette),
56- Surface: appearance.LightPalette.Surface,57+ }
57- SurfaceVariant: appearance.LightPalette.SurfaceVariant,58+}
58- Text: appearance.LightPalette.Text,59+
59- Muted: appearance.LightPalette.Muted,60+func buildSystemviewPalettePayload(palette, fallback ColorPalette) port.ColorPalette {
60- Accent: appearance.LightPalette.Accent,61+ return port.ColorPalette{
61- Border: appearance.LightPalette.Border,62+ Background: nonEmptyColor(palette.Background, fallback.Background),
62- },63+ Surface: nonEmptyColor(palette.Surface, fallback.Surface),
63- DarkPalette: port.ColorPalette{64+ SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),
64- Background: appearance.DarkPalette.Background,65+ Text: nonEmptyColor(palette.Text, fallback.Text),
65- Surface: appearance.DarkPalette.Surface,66+ Muted: nonEmptyColor(palette.Muted, fallback.Muted),
66- SurfaceVariant: appearance.DarkPalette.SurfaceVariant,67+ Accent: nonEmptyColor(palette.Accent, fallback.Accent),
67- Text: appearance.DarkPalette.Text,68+ Border: nonEmptyColor(palette.Border, fallback.Border),
68- Muted: appearance.DarkPalette.Muted,69+ }
69- Accent: appearance.DarkPalette.Accent,70+}
70- Border: appearance.DarkPalette.Border,71+
71- },72+func nonEmptyColor(value, fallback string) string {
73+ if value != "" {
74+ return value
72 }75 }
76+ return fallback
73 }77 }
74 78
75 func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut {79 func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut {
M internal/infrastructure/config/webui_payload_test.go +12 -0
@@ -13,3 +13,15 @@ func TestBuildSystemviewConfigPayload_EmptySearchShortcutsUsesEmptyMap(t *testin
13 require.NotNil(t, payload.SearchShortcuts)13 require.NotNil(t, payload.SearchShortcuts)
14 require.Empty(t, payload.SearchShortcuts)14 require.Empty(t, payload.SearchShortcuts)
15 }15 }
16+
17+func TestBuildSystemviewConfigPayload_FillsMissingPaletteValuesFromDefaults(t *testing.T) {
18+ cfg := DefaultConfig()
19+ cfg.Appearance.LightPalette.Background = ""
20+ cfg.Appearance.DarkPalette.Accent = ""
21+
22+ payload := BuildSystemviewConfigPayload(cfg, nil)
23+ defaults := DefaultConfig()
24+
25+ require.Equal(t, defaults.Appearance.LightPalette.Background, payload.Appearance.LightPalette.Background)
26+ require.Equal(t, defaults.Appearance.DarkPalette.Accent, payload.Appearance.DarkPalette.Accent)
27+}
M internal/infrastructure/handlers/config.go +6 -2
@@ -24,7 +24,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error)
24 }24 }
25 25
26 // Handle processes the save_config message.26 // Handle processes the save_config message.
27-func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {27+func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, payload json.RawMessage) (any, error) {
28 if h == nil {28 if h == nil {
29 return nil, fmt.Errorf("config handler is nil")29 return nil, fmt.Errorf("config handler is nil")
30 }30 }
@@ -39,7 +39,11 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
39 return nil, fmt.Errorf("invalid config format: %w", err)39 return nil, fmt.Errorf("invalid config format: %w", err)
40 }40 }
41 41
42- log.Info().Msg("saving appearance configuration")42+ source := "webui"
43+ if viewID != 0 {
44+ source = fmt.Sprintf("webview:%d", viewID)
45+ }
46+ log.Info().Str("source", source).Msg("saving appearance configuration")
43 47
44 if err := h.saveConfig(ctx, payloadCfg); err != nil {48 if err := h.saveConfig(ctx, payloadCfg); err != nil {
45 log.Error().Err(err).Msg("failed to save config")49 log.Error().Err(err).Msg("failed to save config")
M internal/infrastructure/handlers/homepage/favorites.go +33 -25
@@ -3,12 +3,32 @@ package homepage
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "fmt"
7+ "strings"
6 8
7 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
10 )12 )
11 13
14+func folderIDFromInt64(id *int64) *entity.FolderID {
15+ if id == nil {
16+ return nil
17+ }
18+ folderID := entity.FolderID(*id)
19+ return &folderID
20+}
21+
22+func tagIDsFromInt64s(ids []int64) []entity.TagID {
23+ out := make([]entity.TagID, 0, len(ids))
24+ for _, id := range ids {
25+ if id > 0 {
26+ out = append(out, entity.TagID(id))
27+ }
28+ }
29+ return out
30+}
31+
12 // FavoritesHandlers handles favorite-related messages from the homepage.32 // FavoritesHandlers handles favorite-related messages from the homepage.
13 type FavoritesHandlers struct {33 type FavoritesHandlers struct {
14 favoritesUC port.HomepageFavorites34 favoritesUC port.HomepageFavorites
@@ -63,6 +83,10 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
63 Str("url", req.URL).83 Str("url", req.URL).
64 Msg("handling favorite_create")84 Msg("handling favorite_create")
65 85
86+ if strings.TrimSpace(req.URL) == "" {
87+ return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
88+ }
89+
66 input := port.FavoriteCreateInput{90 input := port.FavoriteCreateInput{
67 URL: req.URL,91 URL: req.URL,
68 Title: req.Title,92 Title: req.Title,
@@ -103,6 +127,10 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
103 Int64("favorite_id", req.ID).127 Int64("favorite_id", req.ID).
104 Msg("handling favorite_update")128 Msg("handling favorite_update")
105 129
130+ if req.ID <= 0 {
131+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
132+ }
133+
106 favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{
107 ID: entity.FavoriteID(req.ID),135 ID: entity.FavoriteID(req.ID),
108 Title: req.Title,136 Title: req.Title,
@@ -138,6 +166,10 @@ func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler {
138 Int64("favorite_id", req.ID).166 Int64("favorite_id", req.ID).
139 Msg("handling favorite_delete")167 Msg("handling favorite_delete")
140 168
169+ if req.ID <= 0 {
170+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
171+ }
172+
141 if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil {173 if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil {
142 return NewErrorResponse(req.RequestID, err), nil174 return NewErrorResponse(req.RequestID, err), nil
143 }175 }
@@ -214,24 +246,6 @@ type setFolderRequest struct {
214 }246 }
215 247
216 // HandleSetFolder handles favorite_set_folder messages.248 // HandleSetFolder handles favorite_set_folder messages.
217-func folderIDFromInt64(id *int64) *entity.FolderID {
218- if id == nil {
219- return nil
220- }
221- folderID := entity.FolderID(*id)
222- return &folderID
223-}
224-
225-func tagIDsFromInt64s(ids []int64) []entity.TagID {
226- out := make([]entity.TagID, 0, len(ids))
227- for _, id := range ids {
228- if id > 0 {
229- out = append(out, entity.TagID(id))
230- }
231- }
232- return out
233-}
234-
235 func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {249 func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
236 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {250 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
237 log := logging.FromContext(ctx)251 log := logging.FromContext(ctx)
@@ -246,13 +260,7 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
246 Int64("favorite_id", req.FavoriteID).260 Int64("favorite_id", req.FavoriteID).
247 Msg("handling favorite_set_folder")261 Msg("handling favorite_set_folder")
248 262
249- var folderID *entity.FolderID263+ if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderIDFromInt64(req.FolderID)); err != nil {
250- if req.FolderID != nil {
251- id := entity.FolderID(*req.FolderID)
252- folderID = &id
253- }
254-
255- if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {
256 return NewErrorResponse(req.RequestID, err), nil264 return NewErrorResponse(req.RequestID, err), nil
257 }265 }
258 266
M internal/infrastructure/handlers/homepage/folders.go +6 -9
@@ -44,6 +44,7 @@ type createFolderRequest struct {
44 RequestID string `json:"requestId"`44 RequestID string `json:"requestId"`
45 Name string `json:"name"`45 Name string `json:"name"`
46 Icon *string `json:"icon"`46 Icon *string `json:"icon"`
47+ ParentID *int64 `json:"parent_id"`
47 }48 }
48 49
49 // HandleCreate handles folder_create messages.50 // HandleCreate handles folder_create messages.
@@ -61,19 +62,15 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
61 Str("name", req.Name).62 Str("name", req.Name).
62 Msg("handling folder_create")63 Msg("handling folder_create")
63 64
64- folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, nil)65+ icon := ""
66+ if req.Icon != nil {
67+ icon = *req.Icon
68+ }
69+ folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, icon, folderIDFromInt64(req.ParentID))
65 if err != nil {70 if err != nil {
66 return NewErrorResponse(req.RequestID, err), nil71 return NewErrorResponse(req.RequestID, err), nil
67 }72 }
68 73
69- // Set icon if provided and persist it
70- if req.Icon != nil && *req.Icon != "" {
71- folder.Icon = *req.Icon
72- if err := h.favoritesUC.UpdateFolder(ctx, folder.ID, folder.Name, folder.Icon); err != nil {
73- return NewErrorResponse(req.RequestID, err), nil
74- }
75- }
76-
77 return NewSuccessResponse(req.RequestID, folder), nil74 return NewSuccessResponse(req.RequestID, folder), nil
78 })75 })
79 }76 }
M internal/infrastructure/handlers/homepage/history.go +20 -0
@@ -3,6 +3,8 @@ package homepage
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "fmt"
7+ "strings"
6 8
7 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -68,6 +70,11 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
68 return NewErrorResponse("", err), nil70 return NewErrorResponse("", err), nil
69 }71 }
70 72
73+ req.Domain = strings.TrimSpace(req.Domain)
74+ if req.Domain == "" {
75+ return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil
76+ }
77+
71 log.Debug().78 log.Debug().
72 Str("request_id", req.RequestID).79 Str("request_id", req.RequestID).
73 Str("domain", req.Domain).80 Str("domain", req.Domain).
@@ -169,11 +176,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
169 return NewErrorResponse("", err), nil176 return NewErrorResponse("", err), nil
170 }177 }
171 178
179+ req.Range = strings.TrimSpace(req.Range)
172 log.Debug().180 log.Debug().
173 Str("request_id", req.RequestID).181 Str("request_id", req.RequestID).
174 Str("range", req.Range).182 Str("range", req.Range).
175 Msg("handling history_delete_range")183 Msg("handling history_delete_range")
176 184
185+ if !isAllowedHistoryDeleteRange(req.Range) {
186+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid range: %s", req.Range)), nil
187+ }
177 if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {188 if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
178 return NewErrorResponse(req.RequestID, err), nil189 return NewErrorResponse(req.RequestID, err), nil
179 }190 }
@@ -182,6 +193,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
182 })193 })
183 }194 }
184 195
196+func isAllowedHistoryDeleteRange(rangeID string) bool {
197+ switch rangeID {
198+ case "hour", "day", "week", "month", "all":
199+ return true
200+ default:
201+ return false
202+ }
203+}
204+
185 // HandleClearAll handles history_clear_all messages.205 // HandleClearAll handles history_clear_all messages.
186 func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {206 func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
187 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {207 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
A internal/infrastructure/netguard/transcode.go +52 -0
@@ -0,0 +1,52 @@
1+package netguard
2+
3+import (
4+ "net"
5+ "net/netip"
6+)
7+
8+var blockedTranscodeIPPfxs = []netip.Prefix{
9+ netip.MustParsePrefix("0.0.0.0/8"),
10+ netip.MustParsePrefix("10.0.0.0/8"),
11+ netip.MustParsePrefix("100.64.0.0/10"),
12+ netip.MustParsePrefix("127.0.0.0/8"),
13+ netip.MustParsePrefix("169.254.0.0/16"),
14+ netip.MustParsePrefix("172.16.0.0/12"),
15+ netip.MustParsePrefix("192.0.0.0/24"),
16+ netip.MustParsePrefix("192.0.2.0/24"),
17+ netip.MustParsePrefix("192.168.0.0/16"),
18+ netip.MustParsePrefix("198.18.0.0/15"),
19+ netip.MustParsePrefix("198.51.100.0/24"),
20+ netip.MustParsePrefix("203.0.113.0/24"),
21+ netip.MustParsePrefix("224.0.0.0/4"),
22+ netip.MustParsePrefix("240.0.0.0/4"),
23+ netip.MustParsePrefix("::/128"),
24+ netip.MustParsePrefix("::1/128"),
25+ netip.MustParsePrefix("64:ff9b::/96"),
26+ netip.MustParsePrefix("64:ff9b:1::/48"),
27+ netip.MustParsePrefix("100::/64"),
28+ netip.MustParsePrefix("2001::/32"),
29+ netip.MustParsePrefix("2001:2::/48"),
30+ netip.MustParsePrefix("2001:db8::/32"),
31+ netip.MustParsePrefix("2002::/16"),
32+ netip.MustParsePrefix("fc00::/7"),
33+ netip.MustParsePrefix("fe80::/10"),
34+ netip.MustParsePrefix("ff00::/8"),
35+}
36+
37+// IsBlockedTranscodeIP reports whether ip belongs to a local, private,
38+// documentation, transition, or otherwise special-purpose range that a media
39+// transcoder must not fetch on behalf of web content.
40+func IsBlockedTranscodeIP(ip net.IP) bool {
41+ addr, ok := netip.AddrFromSlice(ip)
42+ if !ok {
43+ return true
44+ }
45+ addr = addr.Unmap()
46+ for _, prefix := range blockedTranscodeIPPfxs {
47+ if prefix.Contains(addr) {
48+ return true
49+ }
50+ }
51+ return false
52+}
A internal/infrastructure/netguard/transcode_test.go +50 -0
@@ -0,0 +1,50 @@
1+package netguard
2+
3+import (
4+ "net"
5+ "testing"
6+)
7+
8+func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) {
9+ tests := []string{
10+ "127.0.0.1",
11+ "10.0.0.1",
12+ "172.16.0.1",
13+ "192.168.1.1",
14+ "100.64.0.1",
15+ "198.18.0.1",
16+ "192.0.2.1",
17+ "169.254.169.254",
18+ "0.0.0.0",
19+ "64:ff9b::a00:1",
20+ "2001::1",
21+ "2001:db8::1",
22+ "2002:0a00:0001::1",
23+ "fc00::1",
24+ "fe80::1",
25+ "::ffff:127.0.0.1",
26+ }
27+ for _, rawIP := range tests {
28+ t.Run(rawIP, func(t *testing.T) {
29+ if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) {
30+ t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP)
31+ }
32+ })
33+ }
34+}
35+
36+func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) {
37+ if !IsBlockedTranscodeIP(net.ParseIP("")) {
38+ t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true")
39+ }
40+}
41+
42+func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) {
43+ for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} {
44+ t.Run(rawIP, func(t *testing.T) {
45+ if IsBlockedTranscodeIP(net.ParseIP(rawIP)) {
46+ t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP)
47+ }
48+ })
49+ }
50+}
M internal/infrastructure/persistence/sqlite/favorite_repo.go +6 -8
@@ -61,6 +61,11 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite,
61 }61 }
62 }()62 }()
63 63
64+ shortcutKey := sql.NullInt64{}
65+ if fav.ShortcutKey != nil {
66+ shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true}
67+ }
68+
64 queries := r.queries.WithTx(tx)69 queries := r.queries.WithTx(tx)
65 if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{70 if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
66 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},71 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
@@ -73,7 +78,7 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite,
73 return err78 return err
74 }79 }
75 if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{80 if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
76- ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil},81+ ShortcutKey: shortcutKey,
77 ID: int64(fav.ID),82 ID: int64(fav.ID),
78 }); err != nil {83 }); err != nil {
79 return err84 return err
@@ -81,13 +86,6 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite,
81 return tx.Commit()86 return tx.Commit()
82 }87 }
83 88
84-func int64Value(value *int) int64 {
85- if value == nil {
86- return 0
87- }
88- return int64(*value)
89-}
90-
91 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {89 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
92 row, err := r.queries.GetFavoriteByID(ctx, int64(id))90 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
93 if err != nil {91 if err != nil {
M internal/infrastructure/persistence/sqlite/history_repo.go +16 -21
@@ -61,10 +61,12 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro
61 log := logging.FromContext(ctx)61 log := logging.FromContext(ctx)
62 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")62 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
63 63
64+ domain := domainurl.CanonicalDomain(entry.URL)
64 err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{65 err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
65 Url: entry.URL,66 Url: entry.URL,
66 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},67 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
67 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},68 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
69+ Domain: sql.NullString{String: domain, Valid: domain != ""},
68 })70 })
69 if err != nil {71 if err != nil {
70 return err72 return err
@@ -518,11 +520,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H
518 return nil, err520 return nil, err
519 }521 }
520 522
521- entries := make([]*entity.HistoryEntry, len(rows))523+ return historyEntriesFromRows(rows), nil
522- for i := range rows {
523- entries[i] = historyFromRow(rows[i])
524- }
525- return entries, nil
526 }524 }
527 525
528 func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {526 func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -536,11 +534,7 @@ func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.H
536 return nil, err534 return nil, err
537 }535 }
538 536
539- entries := make([]*entity.HistoryEntry, len(rows))537+ return historyEntriesFromRows(rows), nil
540- for i := range rows {
541- entries[i] = historyFromRow(rows[i])
542- }
543- return entries, nil
544 }538 }
545 539
546 func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {540 func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -549,11 +543,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor
549 return nil, err543 return nil, err
550 }544 }
551 545
552- entries := make([]*entity.HistoryEntry, len(rows))546+ return historyEntriesFromRows(rows), nil
553- for i := range rows {
554- entries[i] = historyFromRow(rows[i])
555- }
556- return entries, nil
557 }547 }
558 548
559 func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {549 func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -562,11 +552,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
562 return nil, err552 return nil, err
563 }553 }
564 554
565- entries := make([]*entity.HistoryEntry, len(rows))555+ return historyEntriesFromRows(rows), nil
566- for i := range rows {
567- entries[i] = historyFromRow(rows[i])
568- }
569- return entries, nil
570 }556 }
571 557
572 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {558 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
@@ -599,13 +585,22 @@ func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) err
599 return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})585 return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
600 }586 }
601 587
588+func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error {
589+ return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true})
590+}
591+
602 func (r *historyRepo) DeleteAll(ctx context.Context) error {592 func (r *historyRepo) DeleteAll(ctx context.Context) error {
603 return r.queries.DeleteAllHistory(ctx)593 return r.queries.DeleteAllHistory(ctx)
604 }594 }
605 595
606 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {596 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
597+ rawDomain := domain
607 domain = domainurl.CanonicalDomain(domain)598 domain = domainurl.CanonicalDomain(domain)
608- return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: domain != ""})599+ if domain == "" {
600+ logging.FromContext(ctx).Debug().Str("domain", rawDomain).Msg("skipping delete by domain: empty canonical domain")
601+ return nil
602+ }
603+ return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
609 }604 }
610 605
611 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {606 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
M internal/infrastructure/persistence/sqlite/history_repo_test.go +38 -0
@@ -832,3 +832,41 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
832 require.NoError(t, err)832 require.NoError(t, err)
833 assert.Empty(t, results)833 assert.Empty(t, results)
834 }834 }
835+
836+func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
837+ ctx := historyTestCtx()
838+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
839+
840+ db, err := sqlite.NewConnection(ctx, dbPath)
841+ require.NoError(t, err)
842+ t.Cleanup(func() { _ = db.Close() })
843+
844+ repo := sqlite.NewHistoryRepository(db)
845+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"}))
846+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"}))
847+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"}))
848+
849+ exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 10, 0)
850+ require.NoError(t, err)
851+ require.Len(t, exampleEntries, 2)
852+ for _, entry := range exampleEntries {
853+ assert.Contains(t, entry.URL, "example.com")
854+ }
855+
856+ stats, err := repo.GetDomainStats(ctx, 10)
857+ require.NoError(t, err)
858+ require.NotEmpty(t, stats)
859+ assert.Equal(t, "example.com", stats[0].Domain)
860+ assert.Equal(t, int64(2), stats[0].PageCount)
861+
862+ require.NoError(t, repo.DeleteByDomain(ctx, "example.com"))
863+
864+ exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0)
865+ require.NoError(t, err)
866+ assert.Empty(t, exampleEntries)
867+
868+ remaining, err := repo.GetAllRecentHistory(ctx)
869+ require.NoError(t, err)
870+ require.Len(t, remaining, 1)
871+ assert.Equal(t, "https://other.test", remaining[0].URL)
872+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +7 -0
@@ -230,6 +230,13 @@ func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time
230 return r.repo.DeleteOlderThan(ctx, before)230 return r.repo.DeleteOlderThan(ctx, before)
231 }231 }
232 232
233+func (r *LazyHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error {
234+ if err := r.init(ctx); err != nil {
235+ return err
236+ }
237+ return r.repo.DeleteSince(ctx, since)
238+}
239+
233 func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {240 func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
234 if err := r.init(ctx); err != nil {241 if err := r.init(ctx); err != nil {
235 return err242 return err
A internal/infrastructure/persistence/sqlite/migrations/008_history_domain.sql +34 -0
@@ -0,0 +1,34 @@
1+-- +goose Up
2+-- Store normalized history domains for indexed systemview filtering.
3+
4+ALTER TABLE history ADD COLUMN domain TEXT;
5+
6+UPDATE history
7+SET domain = LOWER(
8+ CASE
9+ WHEN INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') > 0 THEN
10+ SUBSTR(
11+ REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'),
12+ 1,
13+ INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') - 1
14+ )
15+ ELSE REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/')
16+ END
17+)
18+WHERE INSTR(url, '://') > 0;
19+
20+-- Mirror domainurl.CanonicalDomain for legacy rows as closely as SQLite can:
21+-- strip userinfo first, then strip the leading www. alias.
22+UPDATE history
23+SET domain = SUBSTR(domain, INSTR(domain, '@') + 1)
24+WHERE INSTR(domain, '@') > 0;
25+
26+UPDATE history
27+SET domain = SUBSTR(domain, 5)
28+WHERE domain LIKE 'www.%';
29+
30+CREATE INDEX IF NOT EXISTS idx_history_domain_last_visited ON history(domain, last_visited DESC);
31+
32+-- +goose Down
33+DROP INDEX IF EXISTS idx_history_domain_last_visited;
34+ALTER TABLE history DROP COLUMN domain;
M internal/infrastructure/persistence/sqlite/queries/history.sql +11 -22
@@ -3,14 +3,7 @@ SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
3 3
4 -- name: GetRecentHistoryByDomain :many4 -- name: GetRecentHistoryByDomain :many
5 SELECT * FROM history5 SELECT * FROM history
6-WHERE url LIKE '%://' || @domain || '/%'6+WHERE domain = @domain
7- OR url LIKE '%://' || @domain || '?%'
8- OR url LIKE '%://' || @domain || '#%'
9- OR url LIKE '%://' || @domain
10- OR url LIKE '%://www.' || @domain || '/%'
11- OR url LIKE '%://www.' || @domain || '?%'
12- OR url LIKE '%://www.' || @domain || '#%'
13- OR url LIKE '%://www.' || @domain
14 ORDER BY last_visited DESC7 ORDER BY last_visited DESC
15 LIMIT @limit OFFSET @offset;8 LIMIT @limit OFFSET @offset;
16 9
@@ -18,14 +11,15 @@ LIMIT @limit OFFSET @offset;
18 SELECT * FROM history WHERE url = ? LIMIT 1;11 SELECT * FROM history WHERE url = ? LIMIT 1;
19 12
20 -- name: UpsertHistory :exec13 -- name: UpsertHistory :exec
21-INSERT INTO history (url, title, favicon_url)14+INSERT INTO history (url, title, favicon_url, domain)
22-VALUES (?, ?, ?)15+VALUES (?, ?, ?, ?)
23 ON CONFLICT(url)16 ON CONFLICT(url)
24 DO UPDATE SET17 DO UPDATE SET
25 visit_count = visit_count + 1,18 visit_count = visit_count + 1,
26 last_visited = CURRENT_TIMESTAMP,19 last_visited = CURRENT_TIMESTAMP,
27 title = COALESCE(EXCLUDED.title, history.title),20 title = COALESCE(EXCLUDED.title, history.title),
28- favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url);21+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
22+ domain = EXCLUDED.domain;
29 23
30 -- name: IncrementVisitCount :exec24 -- name: IncrementVisitCount :exec
31 UPDATE history25 UPDATE history
@@ -49,6 +43,9 @@ DELETE FROM history WHERE id = ?;
49 -- name: DeleteHistoryOlderThan :exec43 -- name: DeleteHistoryOlderThan :exec
50 DELETE FROM history WHERE last_visited < ?;44 DELETE FROM history WHERE last_visited < ?;
51 45
46+-- name: DeleteHistorySince :exec
47+DELETE FROM history WHERE last_visited >= ?;
48+
52 -- name: DeleteAllHistory :exec49 -- name: DeleteAllHistory :exec
53 DELETE FROM history;50 DELETE FROM history;
54 51
@@ -56,7 +53,7 @@ DELETE FROM history;
56 SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;53 SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;
57 54
58 -- name: GetDomainStats :many55 -- name: GetDomainStats :many
59-SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?;56+SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?;
60 57
61 -- name: GetHourlyDistribution :many58 -- name: GetHourlyDistribution :many
62 SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;59 SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;
@@ -65,15 +62,7 @@ SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_
65 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;62 SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;
66 63
67 -- name: DeleteHistoryByDomain :exec64 -- name: DeleteHistoryByDomain :exec
68-DELETE FROM history65+DELETE FROM history WHERE domain = @domain;
69-WHERE url LIKE '%://' || @domain || '/%'
70- OR url LIKE '%://' || @domain || '?%'
71- OR url LIKE '%://' || @domain || '#%'
72- OR url LIKE '%://' || @domain
73- OR url LIKE '%://www.' || @domain || '/%'
74- OR url LIKE '%://www.' || @domain || '?%'
75- OR url LIKE '%://www.' || @domain || '#%'
76- OR url LIKE '%://www.' || @domain;
77 66
78 -- name: SearchHistoryFTSUrl :many67 -- name: SearchHistoryFTSUrl :many
79 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at68 SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
@@ -99,7 +88,7 @@ ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
99 LIMIT @limit;88 LIMIT @limit;
100 89
101 -- name: SearchHistoryFTSTitle :many90 -- name: SearchHistoryFTSTitle :many
102-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at91+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
103 FROM history_fts fts92 FROM history_fts fts
104 JOIN history h ON fts.rowid = h.id93 JOIN history h ON fts.rowid = h.id
105 WHERE fts.title MATCH @query94 WHERE fts.title MATCH @query
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +54 -34
@@ -35,15 +35,7 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
35 }35 }
36 36
37 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec37 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
38-DELETE FROM history38+DELETE FROM history WHERE domain = ?1
39-WHERE url LIKE '%://' || ?1 || '/%'
40- OR url LIKE '%://' || ?1 || '?%'
41- OR url LIKE '%://' || ?1 || '#%'
42- OR url LIKE '%://' || ?1
43- OR url LIKE '%://www.' || ?1 || '/%'
44- OR url LIKE '%://www.' || ?1 || '?%'
45- OR url LIKE '%://www.' || ?1 || '#%'
46- OR url LIKE '%://www.' || ?1
47 `39 `
48 40
49 func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error {41 func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error {
@@ -69,8 +61,17 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
69 return err61 return err
70 }62 }
71 63
64+const DeleteHistorySince = `-- name: DeleteHistorySince :exec
65+DELETE FROM history WHERE last_visited >= ?
66+`
67+
68+func (q *Queries) DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error {
69+ _, err := q.db.ExecContext(ctx, DeleteHistorySince, lastVisited)
70+ return err
71+}
72+
72 const GetAllMostVisited = `-- name: GetAllMostVisited :many73 const GetAllMostVisited = `-- name: GetAllMostVisited :many
73-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history74+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
74 ORDER BY visit_count DESC, last_visited DESC75 ORDER BY visit_count DESC, last_visited DESC
75 `76 `
76 77
@@ -91,6 +92,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
91 &i.VisitCount,92 &i.VisitCount,
92 &i.LastVisited,93 &i.LastVisited,
93 &i.CreatedAt,94 &i.CreatedAt,
95+ &i.Domain,
94 ); err != nil {96 ); err != nil {
95 return nil, err97 return nil, err
96 }98 }
@@ -106,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
106 }108 }
107 109
108 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
109-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history111+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
110 ORDER BY last_visited DESC112 ORDER BY last_visited DESC
111 `113 `
112 114
@@ -127,6 +129,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
127 &i.VisitCount,129 &i.VisitCount,
128 &i.LastVisited,130 &i.LastVisited,
129 &i.CreatedAt,131 &i.CreatedAt,
132+ &i.Domain,
130 ); err != nil {133 ); err != nil {
131 return nil, err134 return nil, err
132 }135 }
@@ -175,7 +178,7 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G
175 }178 }
176 179
177 const GetDomainStats = `-- name: GetDomainStats :many180 const GetDomainStats = `-- name: GetDomainStats :many
178-SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?181+SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?
179 `182 `
180 183
181 type GetDomainStatsRow struct {184 type GetDomainStatsRow struct {
@@ -214,7 +217,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS
214 }217 }
215 218
216 const GetHistoryByURL = `-- name: GetHistoryByURL :one219 const GetHistoryByURL = `-- name: GetHistoryByURL :one
217-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1220+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history WHERE url = ? LIMIT 1
218 `221 `
219 222
220 func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {223 func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {
@@ -228,6 +231,7 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
228 &i.VisitCount,231 &i.VisitCount,
229 &i.LastVisited,232 &i.LastVisited,
230 &i.CreatedAt,233 &i.CreatedAt,
234+ &i.Domain,
231 )235 )
232 return i, err236 return i, err
233 }237 }
@@ -282,7 +286,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
282 }286 }
283 287
284 const GetMostVisited = `-- name: GetMostVisited :many288 const GetMostVisited = `-- name: GetMostVisited :many
285-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history289+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
286 WHERE last_visited >= datetime('now', ?)290 WHERE last_visited >= datetime('now', ?)
287 ORDER BY visit_count DESC, last_visited DESC291 ORDER BY visit_count DESC, last_visited DESC
288 `292 `
@@ -304,6 +308,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
304 &i.VisitCount,308 &i.VisitCount,
305 &i.LastVisited,309 &i.LastVisited,
306 &i.CreatedAt,310 &i.CreatedAt,
311+ &i.Domain,
307 ); err != nil {312 ); err != nil {
308 return nil, err313 return nil, err
309 }314 }
@@ -319,7 +324,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
319 }324 }
320 325
321 const GetRecentHistory = `-- name: GetRecentHistory :many326 const GetRecentHistory = `-- name: GetRecentHistory :many
322-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?327+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
323 `328 `
324 329
325 type GetRecentHistoryParams struct {330 type GetRecentHistoryParams struct {
@@ -344,6 +349,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
344 &i.VisitCount,349 &i.VisitCount,
345 &i.LastVisited,350 &i.LastVisited,
346 &i.CreatedAt,351 &i.CreatedAt,
352+ &i.Domain,
347 ); err != nil {353 ); err != nil {
348 return nil, err354 return nil, err
349 }355 }
@@ -359,15 +365,8 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
359 }365 }
360 366
361 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many367 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
362-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history368+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
363-WHERE url LIKE '%://' || ?1 || '/%'369+WHERE domain = ?1
364- OR url LIKE '%://' || ?1 || '?%'
365- OR url LIKE '%://' || ?1 || '#%'
366- OR url LIKE '%://' || ?1
367- OR url LIKE '%://www.' || ?1 || '/%'
368- OR url LIKE '%://www.' || ?1 || '?%'
369- OR url LIKE '%://www.' || ?1 || '#%'
370- OR url LIKE '%://www.' || ?1
371 ORDER BY last_visited DESC370 ORDER BY last_visited DESC
372 LIMIT ?3 OFFSET ?2371 LIMIT ?3 OFFSET ?2
373 `372 `
@@ -395,6 +394,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis
395 &i.VisitCount,394 &i.VisitCount,
396 &i.LastVisited,395 &i.LastVisited,
397 &i.CreatedAt,396 &i.CreatedAt,
397+ &i.Domain,
398 ); err != nil {398 ); err != nil {
399 return nil, err399 return nil, err
400 }400 }
@@ -410,7 +410,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis
410 }410 }
411 411
412 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many412 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
413-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history413+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
414 WHERE last_visited >= datetime('now', ?)414 WHERE last_visited >= datetime('now', ?)
415 ORDER BY last_visited DESC415 ORDER BY last_visited DESC
416 `416 `
@@ -432,6 +432,7 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
432 &i.VisitCount,432 &i.VisitCount,
433 &i.LastVisited,433 &i.LastVisited,
434 &i.CreatedAt,434 &i.CreatedAt,
435+ &i.Domain,
435 ); err != nil {436 ); err != nil {
436 return nil, err437 return nil, err
437 }438 }
@@ -474,7 +475,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV
474 }475 }
475 476
476 const SearchHistory = `-- name: SearchHistory :many477 const SearchHistory = `-- name: SearchHistory :many
477-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history478+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
478 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'479 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
479 ORDER BY visit_count DESC, last_visited DESC480 ORDER BY visit_count DESC, last_visited DESC
480 LIMIT ?481 LIMIT ?
@@ -503,6 +504,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
503 &i.VisitCount,504 &i.VisitCount,
504 &i.LastVisited,505 &i.LastVisited,
505 &i.CreatedAt,506 &i.CreatedAt,
507+ &i.Domain,
506 ); err != nil {508 ); err != nil {
507 return nil, err509 return nil, err
508 }510 }
@@ -518,7 +520,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
518 }520 }
519 521
520 const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many522 const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
521-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at523+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
522 FROM history_fts fts524 FROM history_fts fts
523 JOIN history h ON fts.rowid = h.id525 JOIN history h ON fts.rowid = h.id
524 WHERE fts.title MATCH ?1526 WHERE fts.title MATCH ?1
@@ -548,6 +550,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
548 &i.VisitCount,550 &i.VisitCount,
549 &i.LastVisited,551 &i.LastVisited,
550 &i.CreatedAt,552 &i.CreatedAt,
553+ &i.Domain,
551 ); err != nil {554 ); err != nil {
552 return nil, err555 return nil, err
553 }556 }
@@ -576,15 +579,25 @@ type SearchHistoryFTSUrlParams struct {
576 Limit int64 `json:"limit"`579 Limit int64 `json:"limit"`
577 }580 }
578 581
579-func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {582+type SearchHistoryFTSUrlRow struct {
583+ ID int64 `json:"id"`
584+ Url string `json:"url"`
585+ Title sql.NullString `json:"title"`
586+ FaviconUrl sql.NullString `json:"favicon_url"`
587+ VisitCount sql.NullInt64 `json:"visit_count"`
588+ LastVisited sql.NullTime `json:"last_visited"`
589+ CreatedAt sql.NullTime `json:"created_at"`
590+}
591+
592+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) {
580 rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)593 rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
581 if err != nil {594 if err != nil {
582 return nil, err595 return nil, err
583 }596 }
584 defer rows.Close()597 defer rows.Close()
585- items := []History{}598+ items := []SearchHistoryFTSUrlRow{}
586 for rows.Next() {599 for rows.Next() {
587- var i History600+ var i SearchHistoryFTSUrlRow
588 if err := rows.Scan(601 if err := rows.Scan(
589 &i.ID,602 &i.ID,
590 &i.Url,603 &i.Url,
@@ -673,23 +686,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se
673 }686 }
674 687
675 const UpsertHistory = `-- name: UpsertHistory :exec688 const UpsertHistory = `-- name: UpsertHistory :exec
676-INSERT INTO history (url, title, favicon_url)689+INSERT INTO history (url, title, favicon_url, domain)
677-VALUES (?, ?, ?)690+VALUES (?, ?, ?, ?)
678 ON CONFLICT(url)691 ON CONFLICT(url)
679 DO UPDATE SET692 DO UPDATE SET
680 visit_count = visit_count + 1,693 visit_count = visit_count + 1,
681 last_visited = CURRENT_TIMESTAMP,694 last_visited = CURRENT_TIMESTAMP,
682 title = COALESCE(EXCLUDED.title, history.title),695 title = COALESCE(EXCLUDED.title, history.title),
683- favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url)696+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
697+ domain = EXCLUDED.domain
684 `698 `
685 699
686 type UpsertHistoryParams struct {700 type UpsertHistoryParams struct {
687 Url string `json:"url"`701 Url string `json:"url"`
688 Title sql.NullString `json:"title"`702 Title sql.NullString `json:"title"`
689 FaviconUrl sql.NullString `json:"favicon_url"`703 FaviconUrl sql.NullString `json:"favicon_url"`
704+ Domain sql.NullString `json:"domain"`
690 }705 }
691 706
692 func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {707 func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {
693- _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl)708+ _, err := q.db.ExecContext(ctx, UpsertHistory,
709+ arg.Url,
710+ arg.Title,
711+ arg.FaviconUrl,
712+ arg.Domain,
713+ )
694 return err714 return err
695 }715 }
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
@@ -56,6 +56,7 @@ type History struct {
56 VisitCount sql.NullInt64 `json:"visit_count"`56 VisitCount sql.NullInt64 `json:"visit_count"`
57 LastVisited sql.NullTime `json:"last_visited"`57 LastVisited sql.NullTime `json:"last_visited"`
58 CreatedAt sql.NullTime `json:"created_at"`58 CreatedAt sql.NullTime `json:"created_at"`
59+ Domain sql.NullString `json:"domain"`
59 }60 }
60 61
61 type HistoryFt struct {62 type HistoryFt struct {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
@@ -24,6 +24,7 @@ type Querier interface {
24 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error24 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
25 DeleteHistoryByID(ctx context.Context, id int64) error25 DeleteHistoryByID(ctx context.Context, id int64) error
26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
27+ DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error
27 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.28 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
28 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)29 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
29 DeletePermission(ctx context.Context, arg DeletePermissionParams) error30 DeletePermission(ctx context.Context, arg DeletePermissionParams) error
@@ -78,7 +79,7 @@ type Querier interface {
78 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error79 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
79 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)80 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
80 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)81 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
81- SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)82+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error)
82 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)83 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
83 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error84 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
84 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error85 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
M internal/infrastructure/systemviewsbridge/browser_transport.go +28 -10
@@ -51,6 +51,9 @@ func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) {
51 })51 })
52 }52 }
53 53
54+// normalizeBridgeShimResponse accepts the bridge shapes emitted by native and
55+// fallback shims: a direct bridgeResponse envelope, a wrapper with data/error,
56+// or a wrapper whose data field itself contains a bridgeResponse envelope.
54 func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {57 func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {
55 trimmed := bytes.TrimSpace(body)58 trimmed := bytes.TrimSpace(body)
56 if len(trimmed) == 0 {59 if len(trimmed) == 0 {
@@ -67,21 +70,19 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error)
67 _, hasCallback := topLevel["_callback"]70 _, hasCallback := topLevel["_callback"]
68 _, hasData := topLevel["data"]71 _, hasData := topLevel["data"]
69 72
73+ // Native callbacks can already return the final bridgeResponse shape.
70 if (hasSuccess || hasRequestID) && !hasCallback {74 if (hasSuccess || hasRequestID) && !hasCallback {
71 var resp bridgeResponse75 var resp bridgeResponse
72 if err := json.Unmarshal(trimmed, &resp); err != nil {76 if err := json.Unmarshal(trimmed, &resp); err != nil {
73 return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)77 return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)
74 }78 }
75 if resp.RequestID == "" {79 if resp.RequestID == "" {
80+ // Some direct shims omit requestId; preserve correlation for callers.
76 resp.RequestID = requestID81 resp.RequestID = requestID
77 }82 }
78 return json.Marshal(resp)83 return json.Marshal(resp)
79 }84 }
80 85
81- if !hasData {
82- return nil, fmt.Errorf("wrapped bridge response missing data")
83- }
84-
85 var wrapped struct {86 var wrapped struct {
86 Data json.RawMessage `json:"data,omitempty"`87 Data json.RawMessage `json:"data,omitempty"`
87 Error string `json:"error,omitempty"`88 Error string `json:"error,omitempty"`
@@ -90,20 +91,37 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error)
90 return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)91 return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)
91 }92 }
92 if wrapped.Error != "" {93 if wrapped.Error != "" {
94+ // Fetch-style wrappers report transport errors outside the data payload.
93 return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})95 return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})
94 }96 }
97+ if !hasData {
98+ // Wrapped shim responses must carry either data or a top-level error.
99+ return nil, fmt.Errorf("wrapped bridge response missing data")
100+ }
95 if len(wrapped.Data) == 0 {101 if len(wrapped.Data) == 0 {
96 return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})102 return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
97 }103 }
98 104
99- var nested bridgeResponse105+ var nestedFields map[string]json.RawMessage
100- if err := json.Unmarshal(wrapped.Data, &nested); err == nil {106+ if err := json.Unmarshal(wrapped.Data, &nestedFields); err == nil {
101- hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != ""107+ nestedSuccess, nestedHasSuccess := nestedFields["success"]
108+ _, nestedHasRequestID := nestedFields["requestId"]
109+ _, nestedHasError := nestedFields["error"]
110+ successIsBool := false
111+ if nestedHasSuccess {
112+ var successValue bool
113+ successIsBool = json.Unmarshal(nestedSuccess, &successValue) == nil
114+ }
115+ hasNestedEnvelope := successIsBool || (nestedHasRequestID && nestedHasError)
102 if hasNestedEnvelope {116 if hasNestedEnvelope {
103- if nested.RequestID == "" {117+ var nested bridgeResponse
104- nested.RequestID = requestID118+ if err := json.Unmarshal(wrapped.Data, &nested); err == nil {
119+ if nested.RequestID == "" {
120+ // Nested envelopes from direct API wrappers also need caller correlation.
121+ nested.RequestID = requestID
122+ }
123+ return json.Marshal(nested)
105 }124 }
106- return json.Marshal(nested)
107 }125 }
108 }126 }
109 127
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +213 -88
@@ -51,7 +51,8 @@ func (*browserTransport) Available() bool {
51 if !window.Truthy() {51 if !window.Truthy() {
52 return false52 return false
53 }53 }
54- return window.Get("fetch").Truthy() || window.Get("dumber").Truthy()54+ bridge := window.Get("dumber")
55+ return bridge.Truthy() && bridge.Get("postMessage").Truthy()
55 }56 }
56 57
57 func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {58 func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
@@ -83,6 +84,31 @@ func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error)
83 84
84 type webkitTransport struct{}85 type webkitTransport struct{}
85 86
87+type webkitCallbackResult struct {
88+ body []byte
89+ err error
90+}
91+
92+type webkitCallbackWaiter struct {
93+ requestID string
94+ successName string
95+ failureName string
96+ resultCh chan webkitCallbackResult
97+}
98+
99+// webkitCallbacks uses one page-lifetime dispatcher per native callback name.
100+// Requests sharing the same callback names are intentionally serialized because
101+// older native shims do not always echo requestId; late callbacks without a
102+// matching waiter are dropped instead of targeting released js.Func handles.
103+var webkitCallbacks = struct {
104+ sync.Mutex
105+ installed map[string]js.Func
106+ waiters map[string]*webkitCallbackWaiter
107+}{
108+ installed: make(map[string]js.Func),
109+ waiters: make(map[string]*webkitCallbackWaiter),
110+}
111+
86 func (*webkitTransport) Available() bool {112 func (*webkitTransport) Available() bool {
87 window := js.Global().Get("window")113 window := js.Global().Get("window")
88 if !window.Truthy() {114 if !window.Truthy() {
@@ -119,73 +145,126 @@ func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
119 return nil, errors.New("webkit message handler not available")145 return nil, errors.New("webkit message handler not available")
120 }146 }
121 147
122- type result struct {148+ ensureWebKitCallback(window, plan.success, false)
123- body []byte149+ ensureWebKitCallback(window, plan.failure, true)
124- err error150+
151+ resultCh := make(chan webkitCallbackResult, 1)
152+ unregister, err := registerWebKitWaiter(req.requestID, plan, resultCh)
153+ if err != nil {
154+ return nil, err
125 }155 }
126- resultCh := make(chan result, 1)156+
127- var callbackMu sync.Mutex157+ handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
128- callbacksActive := true158+
129- publish := func(res result) {159+ select {
130- callbackMu.Lock()160+ case res := <-resultCh:
131- active := callbacksActive161+ return res.body, res.err
132- callbackMu.Unlock()162+ case <-ctx.Done():
133- if !active {163+ unregister()
134- return164+ return nil, ctx.Err()
135- }
136- select {
137- case resultCh <- res:
138- default:
139- }
140 }165 }
166+}
141 167
142- successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {168+func ensureWebKitCallback(window js.Value, name string, failure bool) {
143- payload, err := stringifyArgs(args)169+ webkitCallbacks.Lock()
144- if err != nil {170+ if _, ok := webkitCallbacks.installed[name]; ok {
145- publish(result{err: err})171+ webkitCallbacks.Unlock()
146- return nil172+ return
147- }173+ }
148- normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)174+ callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
149- publish(result{body: normalized, err: err})175+ handleWebKitCallback(name, failure, args)
150 return nil176 return nil
151 })177 })
152- errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {178+ webkitCallbacks.installed[name] = callback
153- payload, err := stringifyArgs(args)179+ // Installed callbacks are page-lifetime dispatchers. Per-request waiters are
154- if err != nil {180+ // removed on completion/cancellation, so late native responses are dropped
155- publish(result{err: err})181+ // without invoking released js.Func handles.
156- return nil182+ window.Set(name, callback)
183+ webkitCallbacks.Unlock()
184+}
185+
186+func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan webkitCallbackResult) (func(), error) {
187+ waiter := &webkitCallbackWaiter{
188+ requestID: requestID,
189+ successName: plan.success,
190+ failureName: plan.failure,
191+ resultCh: resultCh,
192+ }
193+ webkitCallbacks.Lock()
194+ defer webkitCallbacks.Unlock()
195+ if webkitCallbacks.waiters[plan.success] != nil || webkitCallbacks.waiters[plan.failure] != nil {
196+ return nil, fmt.Errorf("webkit callback already pending for %s", plan.success)
197+ }
198+ webkitCallbacks.waiters[plan.success] = waiter
199+ webkitCallbacks.waiters[plan.failure] = waiter
200+ return func() {
201+ webkitCallbacks.Lock()
202+ defer webkitCallbacks.Unlock()
203+ if webkitCallbacks.waiters[plan.success] == waiter {
204+ delete(webkitCallbacks.waiters, plan.success)
157 }205 }
158- normalized, err := normalizeNativeErrorResponse(req.requestID, payload)206+ if webkitCallbacks.waiters[plan.failure] == waiter {
159- publish(result{body: normalized, err: err})207+ delete(webkitCallbacks.waiters, plan.failure)
160- return nil208+ }
161- })209+ }, nil
210+}
162 211
163- previousSuccess := window.Get(plan.success)212+func handleWebKitCallback(name string, failure bool, args []js.Value) {
164- previousError := window.Get(plan.failure)213+ payload, payloadErr := stringifyArgs(args)
165- window.Set(plan.success, successFn)214+ payloadRequestID := requestIDFromBridgePayload(payload)
166- window.Set(plan.failure, errorFn)
167- defer func() {
168- callbackMu.Lock()
169- callbacksActive = false
170- callbackMu.Unlock()
171- window.Set(plan.success, previousSuccess)
172- window.Set(plan.failure, previousError)
173- successFn.Release()
174- errorFn.Release()
175- }()
176 215
177- handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))216+ webkitCallbacks.Lock()
217+ waiter := webkitCallbacks.waiters[name]
218+ if waiter != nil && payloadRequestID != "" && waiter.requestID != "" && payloadRequestID != waiter.requestID {
219+ webkitCallbacks.Unlock()
220+ warnDroppedBridgeResult("webkit callback request mismatch", fmt.Errorf("got %s, want %s", payloadRequestID, waiter.requestID))
221+ return
222+ }
223+ if waiter != nil {
224+ delete(webkitCallbacks.waiters, waiter.successName)
225+ delete(webkitCallbacks.waiters, waiter.failureName)
226+ }
227+ webkitCallbacks.Unlock()
178 228
229+ if waiter == nil {
230+ warnDroppedBridgeResult("webkit callback without waiter", nil)
231+ return
232+ }
233+
234+ res := webkitCallbackResult{err: payloadErr}
235+ if payloadErr == nil {
236+ if failure {
237+ res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload)
238+ } else {
239+ res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload)
240+ }
241+ }
179 select {242 select {
180- case res := <-resultCh:243+ case waiter.resultCh <- res:
181- return res.body, res.err244+ default:
182- case <-ctx.Done():245+ warnDroppedBridgeResult("webkit callback", res.err)
183- return nil, ctx.Err()
184 }246 }
185 }247 }
186 248
249+func requestIDFromBridgePayload(payload []byte) string {
250+ if len(bytes.TrimSpace(payload)) == 0 {
251+ return ""
252+ }
253+ var envelope struct {
254+ RequestID string `json:"requestId"`
255+ }
256+ if err := json.Unmarshal(payload, &envelope); err != nil {
257+ return ""
258+ }
259+ return envelope.RequestID
260+}
261+
187 func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {262 func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {
188- response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint))263+ fetch := js.Global().Get("fetch")
264+ if !fetch.Truthy() {
265+ return nil, errors.New("fetch API not available")
266+ }
267+ response, err := awaitPromise(ctx, fetch.Invoke(endpoint))
189 if err != nil {268 if err != nil {
190 return nil, err269 return nil, err
191 }270 }
@@ -243,43 +322,77 @@ func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, err
243 }322 }
244 323
245 func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {324 func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
325+ if ctx == nil {
326+ ctx = context.Background()
327+ }
246 type result struct {328 type result struct {
247 value js.Value329 value js.Value
248 err error330 err error
249 }331 }
250 resultCh := make(chan result, 1)332 resultCh := make(chan result, 1)
251 333
334+ promiseCtor := js.Global().Get("Promise")
335+ if !promiseCtor.Truthy() {
336+ return js.Undefined(), errors.New("Promise global not available")
337+ }
338+ var (
339+ cancelMu sync.Mutex
340+ reject = js.Undefined()
341+ )
342+ executor := js.FuncOf(func(_ js.Value, args []js.Value) any {
343+ if len(args) >= 2 {
344+ cancelMu.Lock()
345+ reject = args[1]
346+ cancelMu.Unlock()
347+ }
348+ return nil
349+ })
350+ cancelPromise := promiseCtor.New(executor)
351+ executor.Release()
352+ raceInput := js.Global().Get("Array").New(promise, cancelPromise)
353+ racePromise := promiseCtor.Call("race", raceInput)
354+
355+ done := make(chan struct{})
356+ go func() {
357+ select {
358+ case <-ctx.Done():
359+ cancelMu.Lock()
360+ rejectFn := reject
361+ cancelMu.Unlock()
362+ if rejectFn.Truthy() {
363+ rejectFn.Invoke(ctx.Err().Error())
364+ }
365+ case <-done:
366+ }
367+ }()
368+ defer close(done)
369+
252 var (370 var (
253 callbackMu sync.Mutex371 callbackMu sync.Mutex
254- active = true
255 released bool372 released bool
256- thenFn js.Func373+ thenCb js.Func
257- catchFn js.Func374+ catchCb js.Func
258 )375 )
259- releaseCallbacksLocked := func() {376+ releaseCallbacks := func() {
377+ callbackMu.Lock()
378+ defer callbackMu.Unlock()
260 if released {379 if released {
261 return380 return
262 }381 }
263 released = true382 released = true
264- thenFn.Release()383+ thenCb.Release()
265- catchFn.Release()384+ catchCb.Release()
266 }385 }
267 publish := func(res result) {386 publish := func(res result) {
268- callbackMu.Lock()
269- defer callbackMu.Unlock()
270- if !active {
271- releaseCallbacksLocked()
272- return
273- }
274- active = false
275 select {387 select {
276 case resultCh <- res:388 case resultCh <- res:
277 default:389 default:
390+ warnDroppedBridgeResult("promise", res.err)
278 }391 }
279- releaseCallbacksLocked()
280 }392 }
281 393
282- thenFn = js.FuncOf(func(_ js.Value, args []js.Value) any {394+ thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
395+ defer releaseCallbacks()
283 if len(args) == 0 {396 if len(args) == 0 {
284 publish(result{value: js.Undefined()})397 publish(result{value: js.Undefined()})
285 return nil398 return nil
@@ -287,31 +400,40 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
287 publish(result{value: args[0]})400 publish(result{value: args[0]})
288 return nil401 return nil
289 })402 })
290- catchFn = js.FuncOf(func(_ js.Value, args []js.Value) any {403+ catchCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
404+ defer releaseCallbacks()
291 if len(args) == 0 {405 if len(args) == 0 {
292 publish(result{err: errors.New("promise rejected")})406 publish(result{err: errors.New("promise rejected")})
293 return nil407 return nil
294 }408 }
295- publish(result{err: errors.New(args[0].String())})409+ message := args[0].String()
410+ if ctxErr := ctx.Err(); ctxErr != nil && message == ctxErr.Error() {
411+ publish(result{err: ctxErr})
412+ return nil
413+ }
414+ publish(result{err: errors.New(message)})
296 return nil415 return nil
297 })416 })
417+ attachPromiseCallbacks(racePromise, thenCb, catchCb)
298 418
299- promise.Call("then", thenFn).Call("catch", catchFn)419+ res := <-resultCh
420+ return res.value, res.err
421+}
300 422
301- select {423+func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) {
302- case res := <-resultCh:424+ promise.Call("then", thenCb, catchCb)
303- return res.value, res.err425+}
304- case <-ctx.Done():426+
305- select {427+func warnDroppedBridgeResult(scope string, err error) {
306- case res := <-resultCh:428+ console := js.Global().Get("console")
307- return res.value, res.err429+ if !console.Truthy() || !console.Get("warn").Truthy() {
308- default:430+ return
309- }
310- callbackMu.Lock()
311- active = false
312- callbackMu.Unlock()
313- return js.Undefined(), ctx.Err()
314 }431 }
432+ if err != nil {
433+ console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error())
434+ return
435+ }
436+ console.Call("warn", "dumber systemviews bridge dropped "+scope+" result")
315 }437 }
316 438
317 func stringifyArgs(args []js.Value) ([]byte, error) {439 func stringifyArgs(args []js.Value) ([]byte, error) {
@@ -327,8 +449,11 @@ func stringifyValue(value js.Value) ([]byte, error) {
327 return nil, errors.New("JSON global not available")449 return nil, errors.New("JSON global not available")
328 }450 }
329 stringified := jsonValue.Call("stringify", value)451 stringified := jsonValue.Call("stringify", value)
330- if !stringified.Truthy() {452+ if stringified.Type() == js.TypeString {
331- return nil, errors.New("failed to stringify JS value")453+ return []byte(stringified.String()), nil
454+ }
455+ if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) {
456+ return []byte("null"), nil
332 }457 }
333- return []byte(stringified.String()), nil458+ return nil, errors.New("failed to stringify JS value")
334 }459 }
M internal/infrastructure/systemviewsbridge/browser_transport_test.go +78 -0
@@ -83,6 +83,40 @@ func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) {
83 }83 }
84 }84 }
85 85
86+func TestNormalizeBridgeShimResponse_DoesNotTreatRequestIDDataAsEnvelope(t *testing.T) {
87+ t.Parallel()
88+
89+ got, err := normalizeBridgeShimResponse("req-raw", []byte(`{"data":{"requestId":"external-id","title":"Example"},"_callback":"__dumber_homepage_response"}`))
90+ if err != nil {
91+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
92+ }
93+
94+ var resp bridgeResponse
95+ if err := json.Unmarshal(got, &resp); err != nil {
96+ t.Fatalf("json.Unmarshal() error = %v", err)
97+ }
98+ if string(resp.Data) != `{"requestId":"external-id","title":"Example"}` {
99+ t.Fatalf("data = %s, want raw payload", resp.Data)
100+ }
101+}
102+
103+func TestNormalizeBridgeShimResponse_DoesNotTreatNonBooleanSuccessDataAsEnvelope(t *testing.T) {
104+ t.Parallel()
105+
106+ got, err := normalizeBridgeShimResponse("req-raw-success", []byte(`{"data":{"success":"yes","title":"Example"},"_callback":"__dumber_homepage_response"}`))
107+ if err != nil {
108+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
109+ }
110+
111+ var resp bridgeResponse
112+ if err := json.Unmarshal(got, &resp); err != nil {
113+ t.Fatalf("json.Unmarshal() error = %v", err)
114+ }
115+ if string(resp.Data) != `{"success":"yes","title":"Example"}` {
116+ t.Fatalf("data = %s, want raw payload", resp.Data)
117+ }
118+}
119+
86 func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {120 func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
87 t.Parallel()121 t.Parallel()
88 122
@@ -106,6 +140,50 @@ func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
106 }140 }
107 }141 }
108 142
143+func TestNormalizeBridgeShimResponse_WrapsTopLevelErrorWithoutData(t *testing.T) {
144+ t.Parallel()
145+
146+ got, err := normalizeBridgeShimResponse("req-error", []byte(`{"error":"bridge failed"}`))
147+ if err != nil {
148+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
149+ }
150+
151+ var resp bridgeResponse
152+ if err := json.Unmarshal(got, &resp); err != nil {
153+ t.Fatalf("json.Unmarshal() error = %v", err)
154+ }
155+ if resp.RequestID != "req-error" {
156+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-error")
157+ }
158+ if resp.Error != "bridge failed" {
159+ t.Fatalf("error = %q, want %q", resp.Error, "bridge failed")
160+ }
161+}
162+
163+func TestNormalizeBridgeShimResponse_RejectsMalformedJSON(t *testing.T) {
164+ t.Parallel()
165+
166+ if _, err := normalizeBridgeShimResponse("req-bad", []byte(`{"data":`)); err == nil {
167+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
168+ }
169+}
170+
171+func TestNormalizeBridgeShimResponse_RejectsMissingData(t *testing.T) {
172+ t.Parallel()
173+
174+ if _, err := normalizeBridgeShimResponse("req-missing", []byte(`{"_callback":"__dumber_config_saved"}`)); err == nil {
175+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
176+ }
177+}
178+
179+func TestNormalizeBridgeShimResponse_RejectsEmptyBody(t *testing.T) {
180+ t.Parallel()
181+
182+ if _, err := normalizeBridgeShimResponse("req-empty", nil); err == nil {
183+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
184+ }
185+}
186+
109 func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {187 func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {
110 t.Parallel()188 t.Parallel()
111 189
M internal/infrastructure/systemviewsbridge/client.go +138 -27
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "strings"
8 "sync/atomic"9 "sync/atomic"
9 "time"10 "time"
10 11
@@ -20,8 +21,9 @@ type Transport interface {
20 21
21 // Client sends WebUI message envelopes through the first available transport.22 // Client sends WebUI message envelopes through the first available transport.
22 type Client struct {23 type Client struct {
23- native Transport24+ native Transport
24- fetch Transport25+ fetch Transport
26+ requestTimeout time.Duration
25 }27 }
26 28
27 var _ port.SystemviewConfigService = (*Client)(nil)29 var _ port.SystemviewConfigService = (*Client)(nil)
@@ -30,11 +32,27 @@ var _ port.SystemviewFavoritesService = (*Client)(nil)
30 32
31 var requestSeq atomic.Uint6433 var requestSeq atomic.Uint64
32 34
33-var bridgeRequestTimeout = 15 * time.Second35+const defaultBridgeRequestTimeout = 15 * time.Second
36+
37+// ClientOption customizes a bridge client.
38+type ClientOption func(*Client)
39+
40+// WithRequestTimeout sets the timeout used when callers provide no deadline.
41+func WithRequestTimeout(timeout time.Duration) ClientOption {
42+ return func(c *Client) {
43+ c.requestTimeout = timeout
44+ }
45+}
34 46
35 // NewClient creates a bridge client with native WebKit and fetch transports.47 // NewClient creates a bridge client with native WebKit and fetch transports.
36-func NewClient(native, fetch Transport) *Client {48+func NewClient(native, fetch Transport, opts ...ClientOption) *Client {
37- return &Client{native: native, fetch: fetch}49+ client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout}
50+ for _, opt := range opts {
51+ if opt != nil {
52+ opt(client)
53+ }
54+ }
55+ return client
38 }56 }
39 57
40 // Send builds a WebUI message envelope and sends it through the active transport.58 // Send builds a WebUI message envelope and sends it through the active transport.
@@ -57,6 +75,12 @@ func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte,
57 }75 }
58 76
59 func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {77 func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
78+ if limit < 0 {
79+ return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit)
80+ }
81+ if offset < 0 {
82+ return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset)
83+ }
60 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {84 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
61 RequestID string `json:"requestId"`85 RequestID string `json:"requestId"`
62 Limit int `json:"limit"`86 Limit int `json:"limit"`
@@ -65,6 +89,16 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His
65 }89 }
66 90
67 func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {91 func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
92+ domain = strings.TrimSpace(domain)
93+ if domain == "" {
94+ return nil, fmt.Errorf("timeline-by-domain domain must be non-empty")
95+ }
96+ if limit < 0 {
97+ return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit)
98+ }
99+ if offset < 0 {
100+ return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset)
101+ }
68 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {102 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
69 RequestID string `json:"requestId"`103 RequestID string `json:"requestId"`
70 Domain string `json:"domain"`104 Domain string `json:"domain"`
@@ -74,6 +108,9 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off
74 }108 }
75 109
76 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {110 func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
111+ if limit < 0 {
112+ return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
113+ }
77 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {114 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
78 RequestID string `json:"requestId"`115 RequestID string `json:"requestId"`
79 Query string `json:"query"`116 Query string `json:"query"`
@@ -82,6 +119,9 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity
82 }119 }
83 120
84 func (c *Client) DeleteEntry(ctx context.Context, id int64) error {121 func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
122+ if id <= 0 {
123+ return fmt.Errorf("delete entry id must be positive, got %d", id)
124+ }
85 _, err := request[struct{}](c, ctx, "history_delete_entry", struct {125 _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
86 RequestID string `json:"requestId"`126 RequestID string `json:"requestId"`
87 ID int64 `json:"id"`127 ID int64 `json:"id"`
@@ -90,6 +130,10 @@ func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
90 }130 }
91 131
92 func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {132 func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
133+ rangeID = strings.TrimSpace(rangeID)
134+ if rangeID == "" {
135+ return errors.New("history range ID cannot be empty")
136+ }
93 _, err := request[struct{}](c, ctx, "history_delete_range", struct {137 _, err := request[struct{}](c, ctx, "history_delete_range", struct {
94 RequestID string `json:"requestId"`138 RequestID string `json:"requestId"`
95 Range string `json:"range"`139 Range string `json:"range"`
@@ -97,12 +141,16 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
97 return err141 return err
98 }142 }
99 143
144+// Current intentionally targets the direct HTTP config endpoint rather than a
145+// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
100 func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {146 func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
101 return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {147 return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {
102 RequestID string `json:"requestId"`148 RequestID string `json:"requestId"`
103 }{RequestID: nextRequestID()})149 }{RequestID: nextRequestID()})
104 }150 }
105 151
152+// Default intentionally targets the direct HTTP config endpoint rather than a
153+// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
106 func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {154 func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
107 return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {155 return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
108 RequestID string `json:"requestId"`156 RequestID string `json:"requestId"`
@@ -143,6 +191,9 @@ func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error
143 }191 }
144 192
145 func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {193 func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
194+ if limit < 0 {
195+ return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
196+ }
146 return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {197 return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
147 RequestID string `json:"requestId"`198 RequestID string `json:"requestId"`
148 Limit int `json:"limit"`199 Limit int `json:"limit"`
@@ -150,6 +201,10 @@ func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainSt
150 }201 }
151 202
152 func (c *Client) DeleteDomain(ctx context.Context, domain string) error {203 func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
204+ domain = strings.TrimSpace(domain)
205+ if domain == "" {
206+ return errors.New("history domain cannot be empty")
207+ }
153 _, err := request[struct{}](c, ctx, "history_delete_domain", struct {208 _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
154 RequestID string `json:"requestId"`209 RequestID string `json:"requestId"`
155 Domain string `json:"domain"`210 Domain string `json:"domain"`
@@ -168,10 +223,16 @@ func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateIn
168 }223 }
169 224
170 func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {225 func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
226+ if input.ID <= 0 {
227+ return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
228+ }
171 return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))229 return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
172 }230 }
173 231
174 func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {232 func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
233+ if id <= 0 {
234+ return fmt.Errorf("delete favorite id must be positive, got %d", id)
235+ }
175 _, err := request[struct{}](c, ctx, "favorite_delete", struct {236 _, err := request[struct{}](c, ctx, "favorite_delete", struct {
176 RequestID string `json:"requestId"`237 RequestID string `json:"requestId"`
177 ID int64 `json:"id"`238 ID int64 `json:"id"`
@@ -244,6 +305,9 @@ func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
244 }305 }
245 306
246 func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {307 func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
308+ if favoriteID <= 0 {
309+ return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
310+ }
247 _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {311 _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
248 RequestID string `json:"requestId"`312 RequestID string `json:"requestId"`
249 FavoriteID int64 `json:"favorite_id"`313 FavoriteID int64 `json:"favorite_id"`
@@ -253,6 +317,9 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey
253 }317 }
254 318
255 func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {319 func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
320+ if favoriteID <= 0 {
321+ return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
322+ }
256 _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {323 _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
257 RequestID string `json:"requestId"`324 RequestID string `json:"requestId"`
258 FavoriteID int64 `json:"favorite_id"`325 FavoriteID int64 `json:"favorite_id"`
@@ -261,19 +328,34 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6
261 return err328 return err
262 }329 }
263 330
264-func (c *Client) CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) {331+func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
332+ name = strings.TrimSpace(name)
333+ if name == "" {
334+ return nil, fmt.Errorf("folder name is required")
335+ }
336+ var iconPtr *string
337+ if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
338+ iconPtr = &trimmedIcon
339+ }
265 return request[*entity.Folder](c, ctx, "folder_create", struct {340 return request[*entity.Folder](c, ctx, "folder_create", struct {
266 RequestID string `json:"requestId"`341 RequestID string `json:"requestId"`
267 Name string `json:"name"`342 Name string `json:"name"`
268 Icon *string `json:"icon"`343 Icon *string `json:"icon"`
269 ParentID *int64 `json:"parent_id,omitempty"`344 ParentID *int64 `json:"parent_id,omitempty"`
270- }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID})345+ }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
271 }346 }
272 347
273 func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {348 func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
349+ if id <= 0 {
350+ return fmt.Errorf("update folder id must be positive, got %d", id)
351+ }
352+ name = strings.TrimSpace(name)
353+ if name == "" {
354+ return fmt.Errorf("folder name is required")
355+ }
274 var iconPtr *string356 var iconPtr *string
275- if icon != "" {357+ if trimmed := strings.TrimSpace(icon); trimmed != "" {
276- iconPtr = &icon358+ iconPtr = &trimmed
277 }359 }
278 _, err := request[struct{}](c, ctx, "folder_update", struct {360 _, err := request[struct{}](c, ctx, "folder_update", struct {
279 RequestID string `json:"requestId"`361 RequestID string `json:"requestId"`
@@ -285,6 +367,9 @@ func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string)
285 }367 }
286 368
287 func (c *Client) DeleteFolder(ctx context.Context, id int64) error {369 func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
370+ if id <= 0 {
371+ return fmt.Errorf("delete folder id must be positive, got %d", id)
372+ }
288 _, err := request[struct{}](c, ctx, "folder_delete", struct {373 _, err := request[struct{}](c, ctx, "folder_delete", struct {
289 RequestID string `json:"requestId"`374 RequestID string `json:"requestId"`
290 ID int64 `json:"id"`375 ID int64 `json:"id"`
@@ -293,9 +378,13 @@ func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
293 }378 }
294 379
295 func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {380 func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
381+ name = strings.TrimSpace(name)
382+ if name == "" {
383+ return nil, fmt.Errorf("tag name is required")
384+ }
296 var colorPtr *string385 var colorPtr *string
297- if color != "" {386+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
298- colorPtr = &color387+ colorPtr = &trimmedColor
299 }388 }
300 return request[*entity.Tag](c, ctx, "tag_create", struct {389 return request[*entity.Tag](c, ctx, "tag_create", struct {
301 RequestID string `json:"requestId"`390 RequestID string `json:"requestId"`
@@ -305,13 +394,16 @@ func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag
305 }394 }
306 395
307 func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {396 func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
397+ if id <= 0 {
398+ return fmt.Errorf("update tag id must be positive, got %d", id)
399+ }
308 var namePtr *string400 var namePtr *string
309- if name != "" {401+ if trimmedName := strings.TrimSpace(name); trimmedName != "" {
310- namePtr = &name402+ namePtr = &trimmedName
311 }403 }
312 var colorPtr *string404 var colorPtr *string
313- if color != "" {405+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
314- colorPtr = &color406+ colorPtr = &trimmedColor
315 }407 }
316 _, err := request[struct{}](c, ctx, "tag_update", struct {408 _, err := request[struct{}](c, ctx, "tag_update", struct {
317 RequestID string `json:"requestId"`409 RequestID string `json:"requestId"`
@@ -323,6 +415,9 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er
323 }415 }
324 416
325 func (c *Client) DeleteTag(ctx context.Context, id int64) error {417 func (c *Client) DeleteTag(ctx context.Context, id int64) error {
418+ if id <= 0 {
419+ return fmt.Errorf("delete tag id must be positive, got %d", id)
420+ }
326 _, err := request[struct{}](c, ctx, "tag_delete", struct {421 _, err := request[struct{}](c, ctx, "tag_delete", struct {
327 RequestID string `json:"requestId"`422 RequestID string `json:"requestId"`
328 ID int64 `json:"id"`423 ID int64 `json:"id"`
@@ -331,6 +426,12 @@ func (c *Client) DeleteTag(ctx context.Context, id int64) error {
331 }426 }
332 427
333 func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {428 func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
429+ if favoriteID <= 0 {
430+ return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
431+ }
432+ if tagID <= 0 {
433+ return fmt.Errorf("assign tag id must be positive, got %d", tagID)
434+ }
334 _, err := request[struct{}](c, ctx, "tag_assign", struct {435 _, err := request[struct{}](c, ctx, "tag_assign", struct {
335 RequestID string `json:"requestId"`436 RequestID string `json:"requestId"`
336 FavoriteID int64 `json:"favorite_id"`437 FavoriteID int64 `json:"favorite_id"`
@@ -340,6 +441,12 @@ func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
340 }441 }
341 442
342 func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {443 func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
444+ if favoriteID <= 0 {
445+ return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
446+ }
447+ if tagID <= 0 {
448+ return fmt.Errorf("remove tag id must be positive, got %d", tagID)
449+ }
343 _, err := request[struct{}](c, ctx, "tag_remove", struct {450 _, err := request[struct{}](c, ctx, "tag_remove", struct {
344 RequestID string `json:"requestId"`451 RequestID string `json:"requestId"`
345 FavoriteID int64 `json:"favorite_id"`452 FavoriteID int64 `json:"favorite_id"`
@@ -364,7 +471,7 @@ func (c *Client) transport() Transport {
364 func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {471 func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
365 var zero T472 var zero T
366 473
367- ctx, cancel := withBridgeRequestTimeout(ctx)474+ ctx, cancel := c.withBridgeRequestTimeout(ctx)
368 defer cancel()475 defer cancel()
369 476
370 raw, err := c.Send(ctx, msgType, payload)477 raw, err := c.Send(ctx, msgType, payload)
@@ -375,14 +482,18 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any)
375 return decodeBridgeResponse[T](raw)482 return decodeBridgeResponse[T](raw)
376 }483 }
377 484
378-func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {485+func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
379 if ctx == nil {486 if ctx == nil {
380 ctx = context.Background()487 ctx = context.Background()
381 }488 }
382- if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 {489+ timeout := defaultBridgeRequestTimeout
490+ if c != nil {
491+ timeout = c.requestTimeout
492+ }
493+ if _, ok := ctx.Deadline(); ok || timeout <= 0 {
383 return context.WithCancel(ctx)494 return context.WithCancel(ctx)
384 }495 }
385- return context.WithTimeout(ctx, bridgeRequestTimeout)496+ return context.WithTimeout(ctx, timeout)
386 }497 }
387 498
388 type bridgeResponse struct {499 type bridgeResponse struct {
@@ -393,27 +504,27 @@ type bridgeResponse struct {
393 }504 }
394 505
395 func decodeBridgeResponse[T any](body []byte) (T, error) {506 func decodeBridgeResponse[T any](body []byte) (T, error) {
396- var zero T507+ var result T
397 508
398 var resp bridgeResponse509 var resp bridgeResponse
399 if err := json.Unmarshal(body, &resp); err != nil {510 if err := json.Unmarshal(body, &resp); err != nil {
400- return zero, fmt.Errorf("unmarshal bridge response: %w", err)511+ return result, fmt.Errorf("unmarshal bridge response: %w", err)
401 }512 }
402 if !resp.Success {513 if !resp.Success {
403 if resp.Error == "" {514 if resp.Error == "" {
404- return zero, errors.New("bridge request failed")515+ return result, errors.New("bridge request failed")
405 }516 }
406- return zero, errors.New(resp.Error)517+ return result, errors.New(resp.Error)
407 }518 }
408 if len(resp.Data) == 0 {519 if len(resp.Data) == 0 {
409- return zero, nil520+ return result, nil
410 }521 }
411 522
412- if err := json.Unmarshal(resp.Data, &zero); err != nil {523+ if err := json.Unmarshal(resp.Data, &result); err != nil {
413- return zero, fmt.Errorf("unmarshal bridge data: %w", err)524+ return result, fmt.Errorf("unmarshal bridge data: %w", err)
414 }525 }
415 526
416- return zero, nil527+ return result, nil
417 }528 }
418 529
419 func nextRequestID() string {530 func nextRequestID() string {
M internal/infrastructure/systemviewsbridge/client_test.go +227 -8
@@ -126,6 +126,21 @@ func TestClientTimelineDecodesEntries(t *testing.T) {
126 }126 }
127 }127 }
128 128
129+func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) {
130+ t.Parallel()
131+
132+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)}
133+ client := NewClient(transport, nil)
134+
135+ err := client.DeleteRange(context.Background(), " ")
136+ if err == nil {
137+ t.Fatal("DeleteRange() error = nil, want validation error")
138+ }
139+ if transport.called {
140+ t.Fatal("DeleteRange() sent a request for an invalid range")
141+ }
142+}
143+
129 func TestClientDeleteRangeSendsRange(t *testing.T) {144 func TestClientDeleteRangeSendsRange(t *testing.T) {
130 t.Parallel()145 t.Parallel()
131 146
@@ -357,12 +372,10 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
357 }372 }
358 373
359 func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {374 func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
360- oldTimeout := bridgeRequestTimeout375+ t.Parallel()
361- bridgeRequestTimeout = time.Millisecond
362- t.Cleanup(func() { bridgeRequestTimeout = oldTimeout })
363 376
364 transport := &blockingTransport{available: true}377 transport := &blockingTransport{available: true}
365- client := NewClient(transport, nil)378+ client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond))
366 379
367 _, err := client.Timeline(context.Background(), 25, 0)380 _, err := client.Timeline(context.Background(), 25, 0)
368 if !errors.Is(err, context.DeadlineExceeded) {381 if !errors.Is(err, context.DeadlineExceeded) {
@@ -377,10 +390,10 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) {
377 t.Parallel()390 t.Parallel()
378 391
379 parentID := int64(42)392 parentID := int64(42)
380- transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)}393+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)}
381 client := NewClient(transport, nil)394 client := NewClient(transport, nil)
382 395
383- folder, err := client.CreateFolder(context.Background(), "Nested", &parentID)396+ folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
384 if err != nil {397 if err != nil {
385 t.Fatalf("CreateFolder() error = %v", err)398 t.Fatalf("CreateFolder() error = %v", err)
386 }399 }
@@ -390,6 +403,9 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) {
390 if folder.Name != "Nested" {403 if folder.Name != "Nested" {
391 t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")404 t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
392 }405 }
406+ if folder.Icon != "📁" {
407+ t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
408+ }
393 if folder.ParentID == nil || int64(*folder.ParentID) != parentID {409 if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
394 t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)410 t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
395 }411 }
@@ -417,14 +433,155 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) {
417 if payload.Name != "Nested" {433 if payload.Name != "Nested" {
418 t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")434 t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
419 }435 }
420- if payload.Icon != nil {436+ if payload.Icon == nil || *payload.Icon != "📁" {
421- t.Fatalf("sent icon = %#v, want nil", payload.Icon)437+ t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
422 }438 }
423 if payload.RequestID == "" {439 if payload.RequestID == "" {
424 t.Fatal("sent requestId was empty")440 t.Fatal("sent requestId was empty")
425 }441 }
426 }442 }
427 443
444+func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
445+ t.Parallel()
446+
447+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)}
448+ client := NewClient(transport, nil)
449+
450+ _, err := client.DomainStats(context.Background(), -1)
451+ if err == nil {
452+ t.Fatal("DomainStats() error = nil, want validation error")
453+ }
454+ if transport.called {
455+ t.Fatal("DomainStats() sent a request for a negative limit")
456+ }
457+}
458+
459+func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
460+ t.Parallel()
461+
462+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)}
463+ client := NewClient(transport, nil)
464+
465+ err := client.DeleteDomain(context.Background(), " ")
466+ if err == nil {
467+ t.Fatal("DeleteDomain() error = nil, want validation error")
468+ }
469+ if transport.called {
470+ t.Fatal("DeleteDomain() sent a request for an empty domain")
471+ }
472+}
473+
474+func TestClientUpdateFolderTrimsIcon(t *testing.T) {
475+ t.Parallel()
476+
477+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","success":true}`)}
478+ client := NewClient(transport, nil)
479+
480+ if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil {
481+ t.Fatalf("UpdateFolder() error = %v", err)
482+ }
483+
484+ var msg port.WebUIMessage
485+ if err := json.Unmarshal(transport.last, &msg); err != nil {
486+ t.Fatalf("unmarshal sent envelope: %v", err)
487+ }
488+ if msg.Type != "folder_update" {
489+ t.Fatalf("sent type = %q, want folder_update", msg.Type)
490+ }
491+ var payload struct {
492+ Icon *string `json:"icon"`
493+ }
494+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
495+ t.Fatalf("unmarshal sent payload: %v", err)
496+ }
497+ if payload.Icon == nil || *payload.Icon != "📚" {
498+ t.Fatalf("sent icon = %#v, want 📚", payload.Icon)
499+ }
500+}
501+
502+func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) {
503+ t.Parallel()
504+
505+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)}
506+ client := NewClient(transport, nil)
507+
508+ if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil {
509+ t.Fatalf("UpdateFolder() error = %v", err)
510+ }
511+
512+ var msg port.WebUIMessage
513+ if err := json.Unmarshal(transport.last, &msg); err != nil {
514+ t.Fatalf("unmarshal sent envelope: %v", err)
515+ }
516+ var payload struct {
517+ Icon *string `json:"icon"`
518+ }
519+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
520+ t.Fatalf("unmarshal sent payload: %v", err)
521+ }
522+ if payload.Icon != nil {
523+ t.Fatalf("sent icon = %#v, want nil", payload.Icon)
524+ }
525+}
526+
527+func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
528+ t.Parallel()
529+
530+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)}
531+ client := NewClient(transport, nil)
532+
533+ err := client.DeleteEntry(context.Background(), 0)
534+ if err == nil {
535+ t.Fatal("DeleteEntry() error = nil, want validation error")
536+ }
537+ if transport.called {
538+ t.Fatal("DeleteEntry() sent a request for an invalid id")
539+ }
540+}
541+
542+func TestClientTagActionsTrimFields(t *testing.T) {
543+ t.Parallel()
544+
545+ createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)}
546+ client := NewClient(createTransport, nil)
547+ if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil {
548+ t.Fatalf("CreateTag() error = %v", err)
549+ }
550+ var msg port.WebUIMessage
551+ if err := json.Unmarshal(createTransport.last, &msg); err != nil {
552+ t.Fatalf("unmarshal create envelope: %v", err)
553+ }
554+ var createPayload struct {
555+ Name string `json:"name"`
556+ Color *string `json:"color"`
557+ }
558+ if err := json.Unmarshal(msg.Payload, &createPayload); err != nil {
559+ t.Fatalf("unmarshal create payload: %v", err)
560+ }
561+ if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" {
562+ t.Fatalf("CreateTag payload = %#v", createPayload)
563+ }
564+
565+ updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)}
566+ client = NewClient(updateTransport, nil)
567+ if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil {
568+ t.Fatalf("UpdateTag() error = %v", err)
569+ }
570+ if err := json.Unmarshal(updateTransport.last, &msg); err != nil {
571+ t.Fatalf("unmarshal update envelope: %v", err)
572+ }
573+ var updatePayload struct {
574+ Name *string `json:"name"`
575+ Color *string `json:"color"`
576+ }
577+ if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil {
578+ t.Fatalf("unmarshal update payload: %v", err)
579+ }
580+ if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil {
581+ t.Fatalf("UpdateTag payload = %#v", updatePayload)
582+ }
583+}
584+
428 type fakeTransport struct {585 type fakeTransport struct {
429 available bool586 available bool
430 called bool587 called bool
@@ -452,3 +609,65 @@ func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error)
452 <-ctx.Done()609 <-ctx.Done()
453 return nil, ctx.Err()610 return nil, ctx.Err()
454 }611 }
612+
613+func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
614+ t.Parallel()
615+
616+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)}
617+ client := NewClient(transport, nil)
618+
619+ checks := []struct {
620+ name string
621+ call func() error
622+ }{
623+ {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
624+ {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},
625+ {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }},
626+ {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }},
627+ {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }},
628+ {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }},
629+ {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
630+ {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
631+ {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
632+ {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
633+ {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
634+ {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
635+ {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }},
636+ {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }},
637+ }
638+ for _, check := range checks {
639+ t.Run(check.name, func(t *testing.T) {
640+ if err := check.call(); err == nil {
641+ t.Fatal("error = nil, want validation error")
642+ }
643+ })
644+ }
645+ if transport.called {
646+ t.Fatal("transport called for invalid favorite mutation IDs")
647+ }
648+}
649+
650+func TestClientRejectsNegativeHistoryLimits(t *testing.T) {
651+ t.Parallel()
652+
653+ client := NewClient(&fakeTransport{available: true}, nil)
654+
655+ if _, err := client.Timeline(context.Background(), -1, 0); err == nil {
656+ t.Fatal("Timeline() error = nil, want error")
657+ }
658+ if _, err := client.Timeline(context.Background(), 1, -1); err == nil {
659+ t.Fatal("Timeline() error = nil, want error")
660+ }
661+ if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil {
662+ t.Fatal("TimelineByDomain() error = nil, want error")
663+ }
664+ if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil {
665+ t.Fatal("TimelineByDomain() error = nil, want error")
666+ }
667+ if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil {
668+ t.Fatal("TimelineByDomain() error = nil, want error")
669+ }
670+ if _, err := client.Search(context.Background(), "example", -1); err == nil {
671+ t.Fatal("Search() error = nil, want error")
672+ }
673+}
A internal/infrastructure/transcoder/http_client_test.go +119 -0
@@ -0,0 +1,119 @@
1+package transcoder
2+
3+import (
4+ "context"
5+ "net"
6+ "net/http"
7+ "net/url"
8+ "testing"
9+
10+ "github.com/rs/zerolog"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) {
16+ source, err := url.Parse("http://127.0.0.1/video.mp4")
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+
21+ err = validateHTTPTranscodeURL(context.Background(), source)
22+ if err == nil {
23+ t.Fatal("validateHTTPTranscodeURL() expected error for loopback target")
24+ }
25+}
26+
27+func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
28+ tests := []string{
29+ "100.64.0.1",
30+ "198.18.0.1",
31+ "192.0.2.1",
32+ "64:ff9b::a00:1",
33+ "2001::1",
34+ "2001:db8::1",
35+ "2002:0a00:0001::1",
36+ }
37+ for _, rawIP := range tests {
38+ t.Run(rawIP, func(t *testing.T) {
39+ if !isBlockedTranscodeIP(net.ParseIP(rawIP)) {
40+ t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP)
41+ }
42+ })
43+ }
44+}
45+
46+func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) {
47+ source, err := url.Parse("file:///etc/passwd")
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+
52+ err = validateHTTPTranscodeURL(context.Background(), source)
53+ if err == nil {
54+ t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme")
55+ }
56+}
57+
58+func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) {
59+ source, err := url.Parse("https://93.184.216.34/playlist.m3u8")
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+
64+ err = validateHTTPTranscodeURL(context.Background(), source)
65+ if err == nil {
66+ t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest")
67+ }
68+}
69+
70+func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) {
71+ source, err := url.Parse("https://93.184.216.34/video.mp4")
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+
76+ if err := validateHTTPTranscodeURL(context.Background(), source); err != nil {
77+ t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err)
78+ }
79+}
80+
81+func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) {
82+ p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop())
83+
84+ _, _, err := p.openInputFormatContext(context.Background())
85+ if err == nil {
86+ t.Fatal("openInputFormatContext() expected error for streaming manifest")
87+ }
88+}
89+
90+func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) {
91+ resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}}
92+ if !isStreamingManifestResponse(resp) {
93+ t.Fatal("isStreamingManifestResponse() = false, want true")
94+ }
95+}
96+
97+func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) {
98+ requestURL, err := url.Parse("http://10.0.0.1/private.mp4")
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+
103+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil)
104+ if err == nil {
105+ t.Fatal("validateTranscodeRedirect() expected error for private redirect target")
106+ }
107+}
108+
109+func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) {
110+ requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8")
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+
115+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil)
116+ if err == nil {
117+ t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target")
118+ }
119+}
M internal/infrastructure/transcoder/pipeline.go +108 -5
@@ -5,13 +5,13 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "net"
8 "net/http"9 "net/http"
10+ "net/url"
9 "sort"11 "sort"
10 "strings"12 "strings"
11- "unsafe"
12-
13- "net"
14 "time"13 "time"
14+ "unsafe"
15 15
16 "github.com/bnema/purego"16 "github.com/bnema/purego"
17 "github.com/rs/zerolog"17 "github.com/rs/zerolog"
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/purego-ffmpeg/ffmpeg"19 "github.com/bnema/purego-ffmpeg/ffmpeg"
20 20
21 "github.com/bnema/dumber/internal/application/port"21 "github.com/bnema/dumber/internal/application/port"
22+ "github.com/bnema/dumber/internal/infrastructure/netguard"
22 )23 )
23 24
24 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).25 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
@@ -53,10 +54,104 @@ type pipeline struct {
53 // are not killed. The session context handles cancellation.54 // are not killed. The session context handles cancellation.
54 var httpClient = &http.Client{55 var httpClient = &http.Client{
55 Transport: &http.Transport{56 Transport: &http.Transport{
56- DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,57+ DialContext: safeTranscodeDialContext,
57 TLSHandshakeTimeout: 10 * time.Second,58 TLSHandshakeTimeout: 10 * time.Second,
58 ResponseHeaderTimeout: 15 * time.Second,59 ResponseHeaderTimeout: 15 * time.Second,
59 },60 },
61+ CheckRedirect: validateTranscodeRedirect,
62+}
63+
64+func validateTranscodeRedirect(req *http.Request, via []*http.Request) error {
65+ if len(via) >= 10 {
66+ return fmt.Errorf("stopped after 10 redirects")
67+ }
68+ if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil {
69+ return fmt.Errorf("blocked redirect target: %w", err)
70+ }
71+ return nil
72+}
73+
74+func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
75+ host, port, err := net.SplitHostPort(address)
76+ if err != nil {
77+ return nil, err
78+ }
79+
80+ ips, err := resolveAllowedTranscodeIPs(ctx, host)
81+ if err != nil {
82+ return nil, err
83+ }
84+
85+ dialer := &net.Dialer{Timeout: 10 * time.Second}
86+ var lastErr error
87+ for _, ip := range ips {
88+ conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
89+ if dialErr == nil {
90+ return conn, nil
91+ }
92+ lastErr = dialErr
93+ }
94+ if lastErr != nil {
95+ return nil, lastErr
96+ }
97+ return nil, fmt.Errorf("no allowed transcode source addresses for %s", host)
98+}
99+
100+func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error {
101+ if source == nil || source.Host == "" {
102+ return fmt.Errorf("invalid src")
103+ }
104+ if source.Scheme != "http" && source.Scheme != "https" {
105+ return fmt.Errorf("unsupported src scheme")
106+ }
107+ if IsStreamingManifestURL(source.String()) {
108+ return fmt.Errorf("streaming manifest transcoding is disabled")
109+ }
110+ _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname())
111+ return err
112+}
113+
114+func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) {
115+ host = strings.TrimSpace(host)
116+ if host == "" {
117+ return nil, fmt.Errorf("invalid src host")
118+ }
119+ if ip := net.ParseIP(host); ip != nil {
120+ if isBlockedTranscodeIP(ip) {
121+ return nil, fmt.Errorf("private src host not allowed")
122+ }
123+ return []net.IP{ip}, nil
124+ }
125+
126+ lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
127+ defer cancel()
128+ addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
129+ if err != nil || len(addrs) == 0 {
130+ return nil, fmt.Errorf("src host could not be resolved")
131+ }
132+ ips := make([]net.IP, 0, len(addrs))
133+ for _, addr := range addrs {
134+ if isBlockedTranscodeIP(addr.IP) {
135+ return nil, fmt.Errorf("private src host not allowed")
136+ }
137+ ips = append(ips, addr.IP)
138+ }
139+ return ips, nil
140+}
141+
142+func isBlockedTranscodeIP(ip net.IP) bool {
143+ return netguard.IsBlockedTranscodeIP(ip)
144+}
145+
146+func isStreamingManifestResponse(resp *http.Response) bool {
147+ if resp == nil {
148+ return false
149+ }
150+ if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) {
151+ return true
152+ }
153+ contentType := strings.ToLower(resp.Header.Get("Content-Type"))
154+ return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml")
60 }155 }
61 156
62 func newPipeline(157 func newPipeline(
@@ -394,7 +489,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI
394 489
395 func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {490 func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {
396 if IsStreamingManifestURL(p.sourceURL) {491 if IsStreamingManifestURL(p.sourceURL) {
397- return p.openManifestInputContext()492+ // FFmpeg resolves manifest redirects and segment URLs internally, bypassing
493+ // the guarded Go HTTP client below. Keep untrusted transcode requests on
494+ // custom I/O until manifests can be fetched and rewritten through the same
495+ // SSRF protections.
496+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
398 }497 }
399 return p.openCustomIOInputContext(ctx)498 return p.openCustomIOInputContext(ctx)
400 }499 }
@@ -417,6 +516,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
417 _ = resp.Body.Close()516 _ = resp.Body.Close()
418 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)517 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)
419 }518 }
519+ if isStreamingManifestResponse(resp) {
520+ _ = resp.Body.Close()
521+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
522+ }
420 523
421 body := resp.Body524 body := resp.Body
422 readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {525 readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
M internal/infrastructure/webkit/scheme_handler.go +56 -17
@@ -1,16 +1,19 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "embed"6 "embed"
6 "fmt"7 "fmt"
8+ "io"
7 "io/fs"9 "io/fs"
8 "net/http"10 "net/http"
9 "net/url"11 "net/url"
10- "path/filepath"12+ "path"
11 "strings"13 "strings"
12 "sync"14 "sync"
13 15
16+ "github.com/andybalholm/brotli"
14 "github.com/bnema/dumber/internal/infrastructure/webutil"17 "github.com/bnema/dumber/internal/infrastructure/webutil"
15 "github.com/bnema/dumber/internal/logging"18 "github.com/bnema/dumber/internal/logging"
16 "github.com/bnema/puregotk/v4/gio"19 "github.com/bnema/puregotk/v4/gio"
@@ -21,13 +24,15 @@ import (
21 24
22 // Scheme path constants25 // Scheme path constants
23 const (26 const (
24- HistoryPath = "history"27+ HistoryPath = "history"
25- FavoritesPath = "favorites"28+ FavoritesPath = "favorites"
26- ConfigPath = "config"29+ ConfigPath = "config"
27- ErrorPath = "error"30+ ErrorPath = "error"
28- CrashPath = "crash"31+ CrashPath = "crash"
29- IndexHTML = "index.html"32+ IndexHTML = "index.html"
30- httpGET = "GET"33+ httpGET = "GET"
34+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
35+ systemviewsAssetDir = "systemviews"
31 )36 )
32 37
33 // SchemeRequest represents a request to a custom URI scheme.38 // SchemeRequest represents a request to a custom URI scheme.
@@ -76,7 +81,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
76 81
77 h := &DumbSchemeHandler{82 h := &DumbSchemeHandler{
78 handlers: make(map[string]PageHandler),83 handlers: make(map[string]PageHandler),
79- assetDir: "systemviews",84+ assetDir: systemviewsAssetDir,
80 logger: log.With().Str("component", "scheme-handler").Logger(),85 logger: log.With().Str("component", "scheme-handler").Logger(),
81 }86 }
82 87
@@ -300,7 +305,11 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
300 assetDir = h.assetDir305 assetDir = h.assetDir
301 }306 }
302 307
303- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))308+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
309+ if !ok {
310+ return nil
311+ }
312+
304 data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)313 data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
305 if err != nil {314 if err != nil {
306 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")315 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
@@ -322,10 +331,40 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
322 }331 }
323 }332 }
324 333
334+func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
335+ assetDir = strings.Trim(assetDir, "/")
336+ if assetDir != systemviewsAssetDir {
337+ return "", "", false
338+ }
339+
340+ relPath = strings.TrimLeft(relPath, "/")
341+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
342+ return "", "", false
343+ }
344+
345+ cleanRelPath = path.Clean(relPath)
346+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
347+ return "", "", false
348+ }
349+
350+ fullPath = path.Join(assetDir, cleanRelPath)
351+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
352+ return "", "", false
353+ }
354+ return fullPath, cleanRelPath, true
355+}
356+
325 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {357 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
326 if strings.HasSuffix(relPath, ".wasm") {358 if strings.HasSuffix(relPath, ".wasm") {
327- if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {359+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
328- return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil360+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
361+ if err != nil {
362+ return nil, nil, err
363+ }
364+ if len(data) > maxSystemviewsWASMBytes {
365+ return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
366+ }
367+ return data, nil, nil
329 }368 }
330 }369 }
331 data, err := fs.ReadFile(assets, fullPath)370 data, err := fs.ReadFile(assets, fullPath)
@@ -341,10 +380,10 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
341 assetDir string380 assetDir string
342 file string381 file string
343 }{382 }{
344- HistoryPath: {assetDir: "systemviews", file: IndexHTML},383+ HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
345- FavoritesPath: {assetDir: "systemviews", file: IndexHTML},384+ FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
346- ConfigPath: {assetDir: "systemviews", file: IndexHTML},385+ ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
347- ErrorPath: {assetDir: "systemviews", file: IndexHTML},386+ ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
348 }387 }
349 388
350 if root, ok := rootByHost[u.Host]; ok {389 if root, ok := rootByHost[u.Host]; ok {
@@ -357,7 +396,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
357 396
358 switch u.Opaque {397 switch u.Opaque {
359 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath:398 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath:
360- return "systemviews", IndexHTML, true399+ return systemviewsAssetDir, IndexHTML, true
361 default:400 default:
362 return "", "", false401 return "", "", false
363 }402 }
M internal/infrastructure/webkit/scheme_handler_test.go +55 -0
@@ -114,6 +114,7 @@ func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
114 114
115 resp := h.handleAsset(u)115 resp := h.handleAsset(u)
116 require.NotNil(t, resp)116 require.NotNil(t, resp)
117+ // Charset suffixes are acceptable; assert the MIME type contract only.
117 assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])118 assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
118 assert.Contains(t, string(resp.Data), ".sv-app")119 assert.Contains(t, string(resp.Data), ".sv-app")
119 }120 }
@@ -213,3 +214,57 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
213 assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])214 assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
214 assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])215 assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
215 }216 }
217+
218+func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) {
219+ t.Parallel()
220+
221+ data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
222+ require.NoError(t, err)
223+ require.GreaterOrEqual(t, len(data), 4)
224+ assert.Equal(t, "\x00asm", string(data[:4]))
225+ assert.NotContains(t, headers, "Content-Encoding")
226+}
227+
228+func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
229+ h := NewDumbSchemeHandler(context.Background())
230+ h.SetAssets(assets.WebUIAssets)
231+
232+ for _, raw := range []string{
233+ "dumb://history/../logo.svg",
234+ "dumb://history/nested/../../logo.svg",
235+ } {
236+ t.Run(raw, func(t *testing.T) {
237+ u, err := url.Parse(raw)
238+ require.NoError(t, err)
239+ assert.Nil(t, h.handleAsset(u))
240+ })
241+ }
242+}
243+
244+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
245+ t.Parallel()
246+
247+ fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
248+ require.True(t, ok)
249+ assert.Equal(t, "systemviews/systemviews.css", fullPath)
250+ assert.Equal(t, "systemviews.css", relPath)
251+
252+ invalid := []struct {
253+ name string
254+ assetDir string
255+ relPath string
256+ }{
257+ {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
258+ {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
259+ {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
260+ {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
261+ {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
262+ }
263+ for _, tt := range invalid {
264+ t.Run(tt.name, func(t *testing.T) {
265+ t.Parallel()
266+ _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath)
267+ assert.False(t, ok)
268+ })
269+ }
270+}
M internal/infrastructure/webutil/mime.go +5 -1
@@ -9,7 +9,11 @@ import (
9 // GetMimeType determines the MIME type for a given file path.9 // GetMimeType determines the MIME type for a given file path.
10 // It tries the standard library first, then falls back to common web asset types.10 // It tries the standard library first, then falls back to common web asset types.
11 func GetMimeType(filename string) string {11 func GetMimeType(filename string) string {
12- ext := strings.ToLower(filepath.Ext(filename))12+ lowerName := strings.ToLower(filename)
13+ ext := filepath.Ext(lowerName)
14+ if ext == ".br" {
15+ return GetMimeType(strings.TrimSuffix(lowerName, ext))
16+ }
13 17
14 mt := mime.TypeByExtension(ext)18 mt := mime.TypeByExtension(ext)
15 if mt != "" {19 if mt != "" {
M internal/infrastructure/webutil/mime_test.go +23 -3
@@ -2,10 +2,30 @@ package webutil
2 2
3 import "testing"3 import "testing"
4 4
5-func TestGetMimeType_WASM(t *testing.T) {5+func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) {
6 t.Parallel()6 t.Parallel()
7 7
8- if got := GetMimeType("systemviews.wasm"); got != "application/wasm" {8+ tests := []struct {
9- t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm")9+ name string
10+ filename string
11+ want string
12+ }{
13+ {name: "path input", filename: "assets/systemviews/systemviews.wasm", want: "application/wasm"},
14+ {name: "uppercase extension", filename: "systemviews.WASM", want: "application/wasm"},
15+ {name: "brotli compressed wasm", filename: "systemviews.wasm.br", want: "application/wasm"},
16+ {name: "uppercase brotli compressed wasm", filename: "SYSTEMVIEWS.WASM.BR", want: "application/wasm"},
17+ {name: "plain brotli", filename: "data.br", want: "text/plain"},
18+ {name: "js brotli", filename: "app.js.br", want: "text/javascript; charset=utf-8"},
19+ {name: "tar brotli", filename: "archive.tar.br", want: "application/x-tar"},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ t.Parallel()
25+
26+ if got := GetMimeType(tt.filename); got != tt.want {
27+ t.Fatalf("GetMimeType(%s) = %q, want %q", tt.filename, got, tt.want)
28+ }
29+ })
10 }30 }
11 }31 }
M internal/ui/coordinator/workspace.go +19 -3
@@ -231,13 +231,13 @@ func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, target
231 }231 }
232 if c.getActiveWS == nil {232 if c.getActiveWS == nil {
233 log.Warn().Str("url", targetURL).Msg("active workspace provider not configured")233 log.Warn().Str("url", targetURL).Msg("active workspace provider not configured")
234- return nil234+ return fmt.Errorf("system view toggle: active workspace provider not configured")
235 }235 }
236 236
237 ws, wsView := c.getActiveWS()237 ws, wsView := c.getActiveWS()
238 if ws == nil {238 if ws == nil {
239 log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle")239 log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle")
240- return nil240+ return fmt.Errorf("system view toggle: no active workspace")
241 }241 }
242 242
243 activePane := ws.ActivePane()243 activePane := ws.ActivePane()
@@ -284,6 +284,10 @@ func (c *WorkspaceCoordinator) focusExistingPane(
284 ) {284 ) {
285 oldActivePaneID := ws.ActivePaneID285 oldActivePaneID := ws.ActivePaneID
286 ws.ActivePaneID = paneID286 ws.ActivePaneID = paneID
287+ paneNode := ws.FindPane(paneID)
288+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
289+ setActiveStackIndexForChild(paneNode.Parent, paneNode)
290+ }
287 291
288 if wsView != nil {292 if wsView != nil {
289 wsView.CancelAllPendingHovers()293 wsView.CancelAllPendingHovers()
@@ -296,7 +300,7 @@ func (c *WorkspaceCoordinator) focusExistingPane(
296 } else {300 } else {
297 wsView.FocusPane(paneID)301 wsView.FocusPane(paneID)
298 }302 }
299- if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {303+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
300 c.syncStackedViewActive(ctx, wsView, paneNode)304 c.syncStackedViewActive(ctx, wsView, paneNode)
301 }305 }
302 }306 }
@@ -304,6 +308,18 @@ func (c *WorkspaceCoordinator) focusExistingPane(
304 c.notifyStateChanged()308 c.notifyStateChanged()
305 }309 }
306 310
311+func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
312+ if parent == nil || child == nil {
313+ return
314+ }
315+ for i, candidate := range parent.Children {
316+ if candidate == child {
317+ parent.ActiveStackIndex = i
318+ return
319+ }
320+ }
321+}
322+
307 func (c *WorkspaceCoordinator) prepareSplit(323 func (c *WorkspaceCoordinator) prepareSplit(
308 ctx context.Context,324 ctx context.Context,
309 direction usecase.SplitDirection,325 direction usecase.SplitDirection,
M internal/ui/coordinator/workspace_systemview_test.go +33 -0
@@ -53,6 +53,10 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin
53 ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID}53 ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID}
54 54
55 coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{55 coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
56+ PanesUC: usecase.NewManagePanesUseCase(func() string {
57+ t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane")
58+ return "unused"
59+ }),
56 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {60 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
57 return ws, nil61 return ws, nil
58 },62 },
@@ -91,3 +95,32 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T
91 require.NotNil(t, active)95 require.NotNil(t, active)
92 assert.Equal(t, "https://example.com", active.Pane.URI)96 assert.Equal(t, "https://example.com", active.Pane.URI)
93 }97 }
98+
99+func TestWorkspaceCoordinator_ToggleSystemViewRightUpdatesStackIndexForExistingPane(t *testing.T) {
100+ ctx := context.Background()
101+ first := testLeafNode("pane-1")
102+ first.Pane.URI = "https://example.com"
103+ second := testLeafNode("pane-2")
104+ second.Pane.URI = "dumb://history"
105+ stack := &entity.PaneNode{
106+ ID: "stack-1",
107+ IsStacked: true,
108+ ActiveStackIndex: 0,
109+ Children: []*entity.PaneNode{first, second},
110+ }
111+ first.Parent = stack
112+ second.Parent = stack
113+ ws := &entity.Workspace{ID: "ws-1", Root: stack, ActivePaneID: first.Pane.ID}
114+
115+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
116+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
117+ return ws, nil
118+ },
119+ })
120+
121+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
122+ require.NoError(t, err)
123+
124+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
125+ assert.Equal(t, 1, stack.ActiveStackIndex)
126+}
M internal/ui/input/global_shortcuts.go +24 -29
@@ -104,21 +104,7 @@ func NewGlobalShortcutHandler(
104 Msg("registered global shortcut")104 Msg("registered global shortcut")
105 105
106 if workspace != nil {106 if workspace != nil {
107- // Map action names to action constants for global shortcuts107+ actionMap := globalShortcutActionMap()
108- actionMap := map[string]Action{
109- "toggle_floating_pane": ActionToggleFloatingPane,
110- "toggle-floating-pane": ActionToggleFloatingPane,
111- "consume_or_expel_left": ActionConsumeOrExpelLeft,
112- "consume_or_expel_right": ActionConsumeOrExpelRight,
113- "consume_or_expel_up": ActionConsumeOrExpelUp,
114- "consume_or_expel_down": ActionConsumeOrExpelDown,
115- "toggle_history_systemview": ActionToggleHistorySystemView,
116- "toggle-history-systemview": ActionToggleHistorySystemView,
117- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
118- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
119- "toggle_config_systemview": ActionToggleConfigSystemView,
120- "toggle-config-systemview": ActionToggleConfigSystemView,
121- }
122 108
123 for actionName, actionBinding := range workspace.Shortcuts.Actions {109 for actionName, actionBinding := range workspace.Shortcuts.Actions {
124 action, ok := actionMap[actionName]110 action, ok := actionMap[actionName]
@@ -253,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
253 return true239 return true
254 }240 }
255 241
242+// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
243+func globalShortcutActionMap() map[string]Action {
244+ return map[string]Action{
245+ "toggle_floating_pane": ActionToggleFloatingPane,
246+ "toggle-floating-pane": ActionToggleFloatingPane,
247+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
248+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
249+ "consume_or_expel_right": ActionConsumeOrExpelRight,
250+ "consume-or-expel-right": ActionConsumeOrExpelRight,
251+ "consume_or_expel_up": ActionConsumeOrExpelUp,
252+ "consume-or-expel-up": ActionConsumeOrExpelUp,
253+ "consume_or_expel_down": ActionConsumeOrExpelDown,
254+ "consume-or-expel-down": ActionConsumeOrExpelDown,
255+ "toggle_history_systemview": ActionToggleHistorySystemView,
256+ "toggle-history-systemview": ActionToggleHistorySystemView,
257+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
258+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
259+ "toggle_config_systemview": ActionToggleConfigSystemView,
260+ "toggle-config-systemview": ActionToggleConfigSystemView,
261+ }
262+}
263+
256 // ReloadShortcuts rebuilds global shortcuts from a new config.264 // ReloadShortcuts rebuilds global shortcuts from a new config.
257 // It replaces the GTK shortcut controller to ensure stale shortcuts265 // It replaces the GTK shortcut controller to ensure stale shortcuts
258 // are removed.266 // are removed.
@@ -291,20 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
291 299
292 // Re-register config-driven shortcuts300 // Re-register config-driven shortcuts
293 if workspace != nil {301 if workspace != nil {
294- actionMap := map[string]Action{302+ actionMap := globalShortcutActionMap()
295- "toggle_floating_pane": ActionToggleFloatingPane,
296- "toggle-floating-pane": ActionToggleFloatingPane,
297- "consume_or_expel_left": ActionConsumeOrExpelLeft,
298- "consume_or_expel_right": ActionConsumeOrExpelRight,
299- "consume_or_expel_up": ActionConsumeOrExpelUp,
300- "consume_or_expel_down": ActionConsumeOrExpelDown,
301- "toggle_history_systemview": ActionToggleHistorySystemView,
302- "toggle-history-systemview": ActionToggleHistorySystemView,
303- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
304- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
305- "toggle_config_systemview": ActionToggleConfigSystemView,
306- "toggle-config-systemview": ActionToggleConfigSystemView,
307- }
308 303
309 for actionName, actionBinding := range workspace.Shortcuts.Actions {304 for actionName, actionBinding := range workspace.Shortcuts.Actions {
310 action, ok := actionMap[actionName]305 action, ok := actionMap[actionName]
M internal/ui/input/shortcuts.go +19 -28
@@ -362,35 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
362 // r = rename tab362 // r = rename tab
363 // This follows Zellij-style modal keyboard interface.363 // This follows Zellij-style modal keyboard interface.
364 //364 //
365- // However, these standard browser shortcuts ARE global:365+ // However, these standard browser shortcuts ARE global.
366- // Map action names to action constants
367- actionMap := map[string]Action{
368- "toggle_floating_pane": ActionToggleFloatingPane,
369- "toggle-floating-pane": ActionToggleFloatingPane,
370- "close_pane": ActionClosePane,
371- "close-pane": ActionClosePane,
372- "next_tab": ActionNextTab,
373- "next-tab": ActionNextTab,
374- "previous_tab": ActionPreviousTab,
375- "previous-tab": ActionPreviousTab,
376- "consume_or_expel_left": ActionConsumeOrExpelLeft,
377- "consume-or-expel-left": ActionConsumeOrExpelLeft,
378- "consume_or_expel_right": ActionConsumeOrExpelRight,
379- "consume-or-expel-right": ActionConsumeOrExpelRight,
380- "consume_or_expel_up": ActionConsumeOrExpelUp,
381- "consume-or-expel-up": ActionConsumeOrExpelUp,
382- "consume_or_expel_down": ActionConsumeOrExpelDown,
383- "consume-or-expel-down": ActionConsumeOrExpelDown,
384- "toggle_history_systemview": ActionToggleHistorySystemView,
385- "toggle-history-systemview": ActionToggleHistorySystemView,
386- "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
387- "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
388- "toggle_config_systemview": ActionToggleConfigSystemView,
389- "toggle-config-systemview": ActionToggleConfigSystemView,
390- }
391-
392 for actionName, actionBinding := range cfg.Shortcuts.Actions {366 for actionName, actionBinding := range cfg.Shortcuts.Actions {
393- action, ok := actionMap[actionName]367+ action, ok := configActionToAction[actionName]
394 if !ok {368 if !ok {
395 continue369 continue
396 }370 }
@@ -599,25 +573,42 @@ var configActionToAction = map[string]Action{
599 "toggle-config-systemview": ActionToggleConfigSystemView,573 "toggle-config-systemview": ActionToggleConfigSystemView,
600 574
601 // Tab actions575 // Tab actions
576+ "new_tab": ActionNewTab,
602 "new-tab": ActionNewTab,577 "new-tab": ActionNewTab,
578+ "close_tab": ActionCloseTab,
603 "close-tab": ActionCloseTab,579 "close-tab": ActionCloseTab,
580+ "next_tab": ActionNextTab,
604 "next-tab": ActionNextTab,581 "next-tab": ActionNextTab,
582+ "previous_tab": ActionPreviousTab,
605 "previous-tab": ActionPreviousTab,583 "previous-tab": ActionPreviousTab,
584+ "rename_tab": ActionRenameTab,
606 "rename-tab": ActionRenameTab,585 "rename-tab": ActionRenameTab,
607 586
608 // Pane actions587 // Pane actions
588+ "split_right": ActionSplitRight,
609 "split-right": ActionSplitRight,589 "split-right": ActionSplitRight,
590+ "split_left": ActionSplitLeft,
610 "split-left": ActionSplitLeft,591 "split-left": ActionSplitLeft,
592+ "split_up": ActionSplitUp,
611 "split-up": ActionSplitUp,593 "split-up": ActionSplitUp,
594+ "split_down": ActionSplitDown,
612 "split-down": ActionSplitDown,595 "split-down": ActionSplitDown,
596+ "close_pane": ActionClosePane,
613 "close-pane": ActionClosePane,597 "close-pane": ActionClosePane,
598+ "stack_pane": ActionStackPane,
614 "stack-pane": ActionStackPane,599 "stack-pane": ActionStackPane,
600+ "move_pane_to_tab": ActionMovePaneToTab,
615 "move-pane-to-tab": ActionMovePaneToTab,601 "move-pane-to-tab": ActionMovePaneToTab,
602+ "move_pane_to_next_tab": ActionMovePaneToNextTab,
616 "move-pane-to-next-tab": ActionMovePaneToNextTab,603 "move-pane-to-next-tab": ActionMovePaneToNextTab,
617 604
605+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
618 "consume-or-expel-left": ActionConsumeOrExpelLeft,606 "consume-or-expel-left": ActionConsumeOrExpelLeft,
607+ "consume_or_expel_right": ActionConsumeOrExpelRight,
619 "consume-or-expel-right": ActionConsumeOrExpelRight,608 "consume-or-expel-right": ActionConsumeOrExpelRight,
609+ "consume_or_expel_up": ActionConsumeOrExpelUp,
620 "consume-or-expel-up": ActionConsumeOrExpelUp,610 "consume-or-expel-up": ActionConsumeOrExpelUp,
611+ "consume_or_expel_down": ActionConsumeOrExpelDown,
621 "consume-or-expel-down": ActionConsumeOrExpelDown,612 "consume-or-expel-down": ActionConsumeOrExpelDown,
622 613
623 // Focus navigation614 // Focus navigation
M internal/ui/input/shortcuts_test.go +40 -0
@@ -402,9 +402,49 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
402 }402 }
403 }403 }
404 404
405+func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
406+ tests := []struct {
407+ name string
408+ want Action
409+ }{
410+ {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
411+ {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
412+ {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
413+ }
414+
415+ for _, tt := range tests {
416+ t.Run(tt.name, func(t *testing.T) {
417+ if got := mapConfigAction(tt.name); got != tt.want {
418+ t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
419+ }
420+ })
421+ }
422+}
423+
405 func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {424 func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
406 action := mapConfigAction("toggle-floating-pane")425 action := mapConfigAction("toggle-floating-pane")
407 if action != ActionToggleFloatingPane {426 if action != ActionToggleFloatingPane {
408 t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)427 t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
409 }428 }
410 }429 }
430+
431+func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
432+ actions := globalShortcutActionMap()
433+ tests := []struct {
434+ name string
435+ want Action
436+ }{
437+ {name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft},
438+ {name: "consume-or-expel-right", want: ActionConsumeOrExpelRight},
439+ {name: "consume-or-expel-up", want: ActionConsumeOrExpelUp},
440+ {name: "consume-or-expel-down", want: ActionConsumeOrExpelDown},
441+ }
442+
443+ for _, tt := range tests {
444+ t.Run(tt.name, func(t *testing.T) {
445+ if got := actions[tt.name]; got != tt.want {
446+ t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want)
447+ }
448+ })
449+ }
450+}
A internal/ui/systemviews/action_log.go +20 -0
@@ -0,0 +1,20 @@
1+//go:build !js || !wasm
2+
3+package systemviews
4+
5+import (
6+ "context"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+func logActionMountError(ctx context.Context, mountErr, actionErr error) {
12+ if mountErr == nil && actionErr == nil {
13+ return
14+ }
15+ logging.FromContext(ctx).
16+ Error().
17+ Err(mountErr).
18+ Str("action_error", errorString(actionErr)).
19+ Msg("failed to mount systemview action error")
20+}
A internal/ui/systemviews/action_log_common.go +8 -0
@@ -0,0 +1,8 @@
1+package systemviews
2+
3+func errorString(err error) string {
4+ if err == nil {
5+ return "<nil>"
6+ }
7+ return err.Error()
8+}
A internal/ui/systemviews/action_log_js.go +19 -0
@@ -0,0 +1,19 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import (
6+ "context"
7+ "syscall/js"
8+)
9+
10+func logActionMountError(_ context.Context, mountErr, actionErr error) {
11+ if mountErr == nil && actionErr == nil {
12+ return
13+ }
14+ console := js.Global().Get("console")
15+ if !console.Truthy() || !console.Get("error").Truthy() {
16+ return
17+ }
18+ console.Call("error", "failed to mount systemview action error", errorString(mountErr), errorString(actionErr))
19+}
M internal/ui/systemviews/app.go +219 -51
@@ -20,6 +20,8 @@ type Dependencies struct {
20 }20 }
21 21
22 type App struct {22 type App struct {
23+ // mu protects rendered route state; actionMu protects worker channels.
24+ // The two locks must not be held at the same time.
23 mu sync.Mutex25 mu sync.Mutex
24 actionMu sync.Mutex26 actionMu sync.Mutex
25 deps Dependencies27 deps Dependencies
@@ -46,12 +48,33 @@ type App struct {
46 configError string48 configError string
47 renderedHTML string49 renderedHTML string
48 actionQueue chan DOMAction50 actionQueue chan DOMAction
51+ actionErrorQueue chan error
49 actionCtx context.Context52 actionCtx context.Context
50 actionCancel context.CancelFunc53 actionCancel context.CancelFunc
54+ actionClosed bool
55+ actionWG sync.WaitGroup
51 }56 }
52 57
53 const historyTimelineLimit = 2558 const historyTimelineLimit = 25
54 59
60+func (a *App) lockAction() {
61+ // Lock order: actionMu is never acquired while App.mu is held.
62+ a.actionMu.Lock()
63+}
64+
65+func (a *App) unlockAction() {
66+ a.actionMu.Unlock()
67+}
68+
69+func (a *App) lockState() {
70+ // Lock order: App.mu is never held while acquiring actionMu.
71+ a.mu.Lock()
72+}
73+
74+func (a *App) unlockState() {
75+ a.mu.Unlock()
76+}
77+
55 func NewApp(deps Dependencies) *App {78 func NewApp(deps Dependencies) *App {
56 return &App{deps: deps, currentRoute: RouteUnknown}79 return &App{deps: deps, currentRoute: RouteUnknown}
57 }80 }
@@ -65,16 +88,16 @@ func (a *App) RunWithContext(ctx context.Context) error {
65 return errors.New("app is nil")88 return errors.New("app is nil")
66 }89 }
67 if ctx == nil {90 if ctx == nil {
68- ctx = context.Background()91+ return errors.New("context is nil")
69 }92 }
70 93
71 a.currentRoute = ParseRoute(a.deps.LocationURI)94 a.currentRoute = ParseRoute(a.deps.LocationURI)
72- if err := a.LoadInitial(ctx); err != nil {
73- return err
74- }
75 if a.deps.DOM == nil {95 if a.deps.DOM == nil {
76 return errors.New("DOM not configured")96 return errors.New("DOM not configured")
77 }97 }
98+ if err := a.LoadInitial(ctx); err != nil {
99+ return err
100+ }
78 101
79 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {102 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
80 return err103 return err
@@ -86,43 +109,114 @@ func (a *App) RunWithContext(ctx context.Context) error {
86 }109 }
87 110
88 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {111 func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
89- a.actionMu.Lock()112+ var (
113+ created bool
114+ workerCtx context.Context
115+ cancel context.CancelFunc
116+ queue chan DOMAction
117+ errorQueue chan error
118+ )
119+
120+ a.lockAction()
121+ if a.actionClosed {
122+ a.unlockAction()
123+ return errors.New("systemview app is closed")
124+ }
90 if a.actionQueue == nil {125 if a.actionQueue == nil {
91- workerCtx, cancel := context.WithCancel(ctx)126+ workerCtx, cancel = context.WithCancel(ctx)
92- queue := make(chan DOMAction, 64)127+ queue = make(chan DOMAction, 64)
128+ errorQueue = make(chan error, 1)
93 a.actionQueue = queue129 a.actionQueue = queue
130+ a.actionErrorQueue = errorQueue
94 a.actionCtx = workerCtx131 a.actionCtx = workerCtx
95 a.actionCancel = cancel132 a.actionCancel = cancel
96- go a.runActionWorker(workerCtx, queue)133+ created = true
97 }134 }
98- a.actionMu.Unlock()135+ a.unlockAction()
99- return binder.BindActions(func(action DOMAction) {136+
137+ if err := binder.BindActions(func(action DOMAction) {
100 if !a.enqueueDOMAction(action) && a.actionWorkerActive() {138 if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
101- go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))139+ a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
140+ }
141+ }); err != nil {
142+ if created {
143+ a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
144+ }
145+ return err
146+ }
147+
148+ if created && !a.startActionWorkers(workerCtx, queue, errorQueue) {
149+ a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
150+ a.releaseDOMBindings()
151+ return errors.New("systemview action worker unavailable")
152+ }
153+ return nil
154+}
155+
156+func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool {
157+ if a == nil || ctx == nil || queue == nil || errorQueue == nil {
158+ return false
159+ }
160+ a.lockAction()
161+ ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil
162+ if ready {
163+ a.actionWG.Add(2)
164+ }
165+ a.unlockAction()
166+ if !ready {
167+ return false
168+ }
169+ go func() {
170+ defer a.actionWG.Done()
171+ a.runActionWorker(ctx, queue)
172+ }()
173+ go func() {
174+ defer a.actionWG.Done()
175+ a.runActionErrorWorker(ctx, errorQueue)
176+ }()
177+ return true
178+}
179+
180+func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) {
181+ if a == nil {
182+ return
183+ }
184+ a.lockAction()
185+ if a.actionCtx == ctx && a.actionQueue == queue {
186+ a.actionCancel = nil
187+ a.actionCtx = nil
188+ a.actionQueue = nil
189+ a.actionErrorQueue = nil
190+ if cancel != nil {
191+ cancel()
102 }192 }
103- })193+ close(queue)
194+ close(errorQueue)
195+ }
196+ a.unlockAction()
104 }197 }
105 198
106 func (a *App) actionWorkerActive() bool {199 func (a *App) actionWorkerActive() bool {
107 if a == nil {200 if a == nil {
108 return false201 return false
109 }202 }
110- a.actionMu.Lock()203+ a.lockAction()
204+ closed := a.actionClosed
111 queue := a.actionQueue205 queue := a.actionQueue
112 ctx := a.actionCtx206 ctx := a.actionCtx
113- a.actionMu.Unlock()207+ a.unlockAction()
114- return queue != nil && ctx != nil && ctx.Err() == nil208+ return !closed && queue != nil && ctx != nil && ctx.Err() == nil
115 }209 }
116 210
117 func (a *App) enqueueDOMAction(action DOMAction) bool {211 func (a *App) enqueueDOMAction(action DOMAction) bool {
118 if a == nil {212 if a == nil {
119 return false213 return false
120 }214 }
121- a.actionMu.Lock()215+ a.lockAction()
216+ defer a.unlockAction()
122 queue := a.actionQueue217 queue := a.actionQueue
123 ctx := a.actionCtx218 ctx := a.actionCtx
124- a.actionMu.Unlock()219+ if a.actionClosed || queue == nil || ctx == nil {
125- if queue == nil || ctx == nil {
126 return false220 return false
127 }221 }
128 select {222 select {
@@ -135,45 +229,100 @@ func (a *App) enqueueDOMAction(action DOMAction) bool {
135 }229 }
136 }230 }
137 231
232+func (a *App) enqueueActionError(err error) {
233+ if a == nil || err == nil {
234+ return
235+ }
236+ a.lockAction()
237+ defer a.unlockAction()
238+ queue := a.actionErrorQueue
239+ ctx := a.actionCtx
240+ if a.actionClosed || queue == nil || ctx == nil {
241+ return
242+ }
243+ select {
244+ case <-ctx.Done():
245+ return
246+ case queue <- err:
247+ default:
248+ }
249+}
250+
138 func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {251 func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
139 for {252 for {
140 select {253 select {
141 case <-ctx.Done():254 case <-ctx.Done():
142 return255 return
143 case action, ok := <-queue:256 case action, ok := <-queue:
144- if !ok {257+ if !ok || ctx.Err() != nil {
145 return258 return
146 }259 }
147 if err := a.HandleDOMAction(ctx, action); err != nil {260 if err := a.HandleDOMAction(ctx, action); err != nil {
148- a.surfaceActionError(err)261+ a.enqueueActionError(err)
262+ }
263+ }
264+ }
265+}
266+
267+func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) {
268+ for {
269+ select {
270+ case <-ctx.Done():
271+ return
272+ case err, ok := <-queue:
273+ if !ok || ctx.Err() != nil {
274+ return
149 }275 }
276+ a.surfaceActionError(ctx, err)
150 }277 }
151 }278 }
152 }279 }
153 280
154-func (a *App) surfaceActionError(err error) {281+func (a *App) surfaceActionError(ctx context.Context, err error) {
155 if a == nil || err == nil {282 if a == nil || err == nil {
156 return283 return
157 }284 }
158- a.mu.Lock()285+ a.lockState()
159 a.renderRouteError(err)286 a.renderRouteError(err)
160- a.mu.Unlock()287+ html := a.renderedHTML
161- _ = a.mountRenderedHTML()288+ a.unlockState()
289+ if mountErr := a.mountHTML(html); mountErr != nil {
290+ logActionMountError(ctx, mountErr, err)
291+ }
162 }292 }
163 293
164 func (a *App) Close() {294 func (a *App) Close() {
165 if a == nil {295 if a == nil {
166 return296 return
167 }297 }
168- a.actionMu.Lock()298+ a.lockAction()
169 cancel := a.actionCancel299 cancel := a.actionCancel
300+ actionQueue := a.actionQueue
301+ actionErrorQueue := a.actionErrorQueue
302+ a.actionClosed = true
170 a.actionCancel = nil303 a.actionCancel = nil
171 a.actionCtx = nil304 a.actionCtx = nil
172 a.actionQueue = nil305 a.actionQueue = nil
173- a.actionMu.Unlock()306+ a.actionErrorQueue = nil
307+ a.unlockAction()
308+
174 if cancel != nil {309 if cancel != nil {
175 cancel()310 cancel()
176 }311 }
312+ a.actionWG.Wait()
313+ if actionQueue != nil {
314+ close(actionQueue)
315+ }
316+ if actionErrorQueue != nil {
317+ close(actionErrorQueue)
318+ }
319+ a.releaseDOMBindings()
320+}
321+
322+func (a *App) releaseDOMBindings() {
323+ if a == nil {
324+ return
325+ }
177 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {326 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
178 releaser.Release()327 releaser.Release()
179 }328 }
@@ -200,6 +349,7 @@ func (a *App) LoadInitial(ctx context.Context) error {
200 a.resetRouteState()349 a.resetRouteState()
201 a.renderedHTML = renderAppFrame(renderedPage{350 a.renderedHTML = renderAppFrame(renderedPage{
202 route: a.currentRoute,351 route: a.currentRoute,
352+ title: routeDocumentTitle(a.currentRoute),
203 subtitle: string(a.currentRoute),353 subtitle: string(a.currentRoute),
204 body: placeholderHTML(a.currentRoute),354 body: placeholderHTML(a.currentRoute),
205 }, a.shellTheme)355 }, a.shellTheme)
@@ -219,11 +369,25 @@ func (a *App) renderRouteError(err error) {
219 }369 }
220 a.renderedHTML = renderAppFrame(renderedPage{370 a.renderedHTML = renderAppFrame(renderedPage{
221 route: a.currentRoute,371 route: a.currentRoute,
372+ title: routeDocumentTitle(a.currentRoute) + " — Error",
222 subtitle: routeSubtitle(a.currentRoute),373 subtitle: routeSubtitle(a.currentRoute),
223 body: errorStateHTML(message),374 body: errorStateHTML(message),
224 }, a.shellTheme)375 }, a.shellTheme)
225 }376 }
226 377
378+func routeDocumentTitle(route Route) string {
379+ switch route {
380+ case RouteHistory:
381+ return "History"
382+ case RouteFavorites:
383+ return "Favorites"
384+ case RouteConfig:
385+ return "Config"
386+ default:
387+ return "Dumber System View"
388+ }
389+}
390+
227 func routeSubtitle(route Route) string {391 func routeSubtitle(route Route) string {
228 switch route {392 switch route {
229 case RouteHistory:393 case RouteHistory:
@@ -242,6 +406,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
242 a.resetRouteState()406 a.resetRouteState()
243 a.renderedHTML = renderAppFrame(renderedPage{407 a.renderedHTML = renderAppFrame(renderedPage{
244 route: a.currentRoute,408 route: a.currentRoute,
409+ title: routeDocumentTitle(RouteHistory),
245 subtitle: "Recent visits",410 subtitle: "Recent visits",
246 body: placeholderHTML(a.currentRoute),411 body: placeholderHTML(a.currentRoute),
247 }, a.shellTheme)412 }, a.shellTheme)
@@ -266,20 +431,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
266 a.historyEntries = entries431 a.historyEntries = entries
267 a.historyAnalytics = analytics432 a.historyAnalytics = analytics
268 a.historyDomainStats = domains433 a.historyDomainStats = domains
434+ data := historyRenderData{
435+ Entries: entries,
436+ Analytics: analytics,
437+ Domains: domains,
438+ Query: a.historyQuery,
439+ DomainFilter: a.historyDomainFilter,
440+ Offset: a.historyOffset,
441+ Limit: historyTimelineLimit,
442+ Notice: a.historyNotice,
443+ Error: a.historyError,
444+ }
269 a.renderedHTML = renderAppFrame(renderedPage{445 a.renderedHTML = renderAppFrame(renderedPage{
270 route: RouteHistory,446 route: RouteHistory,
447+ title: historyDocumentTitle(data),
271 subtitle: "Recent visits",448 subtitle: "Recent visits",
272- body: historyHTML(historyRenderData{449+ body: historyHTML(data),
273- Entries: entries,
274- Analytics: analytics,
275- Domains: domains,
276- Query: a.historyQuery,
277- DomainFilter: a.historyDomainFilter,
278- Offset: a.historyOffset,
279- Limit: historyTimelineLimit,
280- Notice: a.historyNotice,
281- Error: a.historyError,
282- }),
283 }, a.shellTheme)450 }, a.shellTheme)
284 return nil451 return nil
285 }452 }
@@ -288,11 +455,7 @@ func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, e
288 query := strings.TrimSpace(a.historyQuery)455 query := strings.TrimSpace(a.historyQuery)
289 domain := strings.TrimSpace(a.historyDomainFilter)456 domain := strings.TrimSpace(a.historyDomainFilter)
290 if query != "" {457 if query != "" {
291- entries, err := a.deps.History.Search(ctx, query, historyTimelineLimit)458+ return a.deps.History.Search(ctx, query, historyTimelineLimit)
292- if err != nil || domain == "" {
293- return entries, err
294- }
295- return filterHistoryEntriesByDomain(entries, domain), nil
296 }459 }
297 if domain != "" {460 if domain != "" {
298 return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset)461 return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset)
@@ -305,6 +468,7 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
305 a.resetRouteState()468 a.resetRouteState()
306 a.renderedHTML = renderAppFrame(renderedPage{469 a.renderedHTML = renderAppFrame(renderedPage{
307 route: a.currentRoute,470 route: a.currentRoute,
471+ title: routeDocumentTitle(RouteFavorites),
308 subtitle: "Saved bookmarks",472 subtitle: "Saved bookmarks",
309 body: placeholderHTML(a.currentRoute),473 body: placeholderHTML(a.currentRoute),
310 }, a.shellTheme)474 }, a.shellTheme)
@@ -329,18 +493,20 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
329 a.favorites = favorites493 a.favorites = favorites
330 a.folders = folders494 a.folders = folders
331 a.tags = tags495 a.tags = tags
496+ data := favoritesRenderData{
497+ Favorites: favorites,
498+ Folders: folders,
499+ Tags: tags,
500+ FolderFilter: a.favoriteFolderFilter,
501+ TagFilter: a.favoriteTagFilter,
502+ Notice: a.favoritesNotice,
503+ Error: a.favoritesError,
504+ }
332 a.renderedHTML = renderAppFrame(renderedPage{505 a.renderedHTML = renderAppFrame(renderedPage{
333 route: RouteFavorites,506 route: RouteFavorites,
507+ title: favoritesDocumentTitle(data),
334 subtitle: "Saved bookmarks",508 subtitle: "Saved bookmarks",
335- body: favoritesHTML(favoritesRenderData{509+ body: favoritesHTML(data),
336- Favorites: favorites,
337- Folders: folders,
338- Tags: tags,
339- FolderFilter: a.favoriteFolderFilter,
340- TagFilter: a.favoriteTagFilter,
341- Notice: a.favoritesNotice,
342- Error: a.favoritesError,
343- }),
344 }, a.shellTheme)510 }, a.shellTheme)
345 return nil511 return nil
346 }512 }
@@ -350,6 +516,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
350 a.resetRouteState()516 a.resetRouteState()
351 a.renderedHTML = renderAppFrame(renderedPage{517 a.renderedHTML = renderAppFrame(renderedPage{
352 route: a.currentRoute,518 route: a.currentRoute,
519+ title: routeDocumentTitle(RouteConfig),
353 subtitle: "Browser settings",520 subtitle: "Browser settings",
354 body: placeholderHTML(a.currentRoute),521 body: placeholderHTML(a.currentRoute),
355 }, a.shellTheme)522 }, a.shellTheme)
@@ -373,6 +540,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
373 a.keybindings = keybindings540 a.keybindings = keybindings
374 a.renderedHTML = renderAppFrame(renderedPage{541 a.renderedHTML = renderAppFrame(renderedPage{
375 route: RouteConfig,542 route: RouteConfig,
543+ title: "Config — Dumber",
376 subtitle: "Browser settings",544 subtitle: "Browser settings",
377 body: configHTML(configRenderData{545 body: configHTML(configRenderData{
378 Config: config,546 Config: config,
M internal/ui/systemviews/app_test.go +224 -5
@@ -19,6 +19,11 @@ func TestParseRoute(t *testing.T) {
19 uri string19 uri string
20 want Route20 want Route
21 }{21 }{
22+ {name: "empty", uri: "", want: RouteUnknown},
23+ {name: "scheme only", uri: "://", want: RouteUnknown},
24+ {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown},
25+ {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown},
26+ {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown},
22 {name: "history host", uri: "dumb://history", want: RouteHistory},27 {name: "history host", uri: "dumb://history", want: RouteHistory},
23 {name: "history opaque", uri: "dumb:history", want: RouteHistory},28 {name: "history opaque", uri: "dumb:history", want: RouteHistory},
24 {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},29 {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
@@ -81,6 +86,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
81 assert.NotContains(t, app.renderedHTML, "<body")86 assert.NotContains(t, app.renderedHTML, "<body")
82 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)87 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
83 assert.Contains(t, app.renderedHTML, `data-route="history"`)88 assert.Contains(t, app.renderedHTML, `data-route="history"`)
89+ assert.Contains(t, app.renderedHTML, `data-page-title="History — 1 entry"`)
84 assert.Contains(t, app.renderedHTML, `sv-section`)90 assert.Contains(t, app.renderedHTML, `sv-section`)
85 assert.Contains(t, app.renderedHTML, `class="sv-list"`)91 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
86 }92 }
@@ -133,11 +139,13 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
133 assert.Contains(t, dom.html, "Search result")139 assert.Contains(t, dom.html, "Search result")
134 assert.Contains(t, dom.html, "Query: example")140 assert.Contains(t, dom.html, "Query: example")
135 141
142+ app.historyOffset = historyTimelineLimit
136 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{143 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
137 Action: historyActionDeleteEntry,144 Action: historyActionDeleteEntry,
138 Data: map[string]string{"id": "42"},145 Data: map[string]string{"id": "42"},
139 }))146 }))
140 assert.Equal(t, int64(42), history.deletedEntryID)147 assert.Equal(t, int64(42), history.deletedEntryID)
148+ assert.Equal(t, 0, app.historyOffset)
141 assert.Contains(t, dom.html, "Deleted history entry")149 assert.Contains(t, dom.html, "Deleted history entry")
142 150
143 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{151 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -154,6 +162,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
154 }))162 }))
155 assert.True(t, history.domainCalled)163 assert.True(t, history.domainCalled)
156 assert.Equal(t, "example.com", history.domain)164 assert.Equal(t, "example.com", history.domain)
165+ assert.Empty(t, app.historyQuery)
157 166
158 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{167 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
159 Action: historyActionDeleteDomain,168 Action: historyActionDeleteDomain,
@@ -181,6 +190,37 @@ func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
181 })190 })
182 }191 }
183 192
193+func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) {
194+ bindErr := errors.New("bind failed")
195+ dom := &failingActionDOM{bindErr: bindErr}
196+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
197+
198+ err := app.RunWithContext(context.Background())
199+ require.ErrorIs(t, err, bindErr)
200+ assert.Nil(t, app.actionQueue)
201+ assert.Nil(t, app.actionErrorQueue)
202+ assert.Nil(t, app.actionCtx)
203+ assert.False(t, app.actionWorkerActive())
204+ require.NotPanics(t, app.Close)
205+}
206+
207+func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) {
208+ dom := &fakeActionDOM{}
209+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
210+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
211+
212+ ctx, cancel := context.WithCancel(context.Background())
213+ cancel()
214+ err := app.RunWithContext(ctx)
215+
216+ require.ErrorContains(t, err, "systemview action worker unavailable")
217+ assert.True(t, dom.released)
218+ assert.Nil(t, app.actionQueue)
219+ assert.Nil(t, app.actionErrorQueue)
220+ assert.Nil(t, app.actionCtx)
221+ assert.False(t, app.actionWorkerActive())
222+}
223+
184 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {224 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
185 t.Parallel()225 t.Parallel()
186 226
@@ -278,6 +318,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
278 assert.NotContains(t, app.renderedHTML, "<body")318 assert.NotContains(t, app.renderedHTML, "<body")
279 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)319 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
280 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)320 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
321+ assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
281 assert.Contains(t, app.renderedHTML, `sv-section`)322 assert.Contains(t, app.renderedHTML, `sv-section`)
282 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)323 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
283 }324 }
@@ -306,6 +347,8 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
306 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)347 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
307 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)348 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
308 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)349 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
350+ assert.Contains(t, app.renderedHTML, `name="tags"`)
351+ assert.Contains(t, app.renderedHTML, `value="7"`)
309 assert.Contains(t, app.renderedHTML, "Read Later")352 assert.Contains(t, app.renderedHTML, "Read Later")
310 assert.Contains(t, app.renderedHTML, "Go")353 assert.Contains(t, app.renderedHTML, "Go")
311 assert.Contains(t, app.renderedHTML, "Shortcut 3")354 assert.Contains(t, app.renderedHTML, "Shortcut 3")
@@ -322,11 +365,12 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
322 365
323 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{366 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
324 Action: favoriteActionCreate,367 Action: favoriteActionCreate,
325- Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5"},368+ Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"},
326 }))369 }))
327 require.NotNil(t, favorites.createdFavorite.FolderID)370 require.NotNil(t, favorites.createdFavorite.FolderID)
328 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)371 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
329 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)372 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
373+ assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
330 assert.Contains(t, dom.html, "Added favorite New")374 assert.Contains(t, dom.html, "Added favorite New")
331 375
332 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{376 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -362,6 +406,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
362 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)406 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
363 assert.Nil(t, app.favoriteFolderFilter)407 assert.Nil(t, app.favoriteFolderFilter)
364 408
409+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
410+ Action: folderActionCreate,
411+ Data: map[string]string{"name": "Reading", "icon": "📚"},
412+ }))
413+ assert.Equal(t, "Reading", favorites.createdFolder)
414+ assert.Equal(t, "📚", favorites.createdFolderIcon)
415+ assert.Contains(t, dom.html, "Created folder 📚 Reading")
416+
365 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{417 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
366 Action: favoriteActionDelete,418 Action: favoriteActionDelete,
367 Data: map[string]string{"id": "42"},419 Data: map[string]string{"id": "42"},
@@ -492,6 +544,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
492 assert.NotContains(t, app.renderedHTML, "<body")544 assert.NotContains(t, app.renderedHTML, "<body")
493 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)545 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
494 assert.Contains(t, app.renderedHTML, `data-route="config"`)546 assert.Contains(t, app.renderedHTML, `data-route="config"`)
547+ assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
495 assert.Contains(t, app.renderedHTML, `sv-section`)548 assert.Contains(t, app.renderedHTML, `sv-section`)
496 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)549 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
497 }550 }
@@ -580,12 +633,20 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
580 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)633 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
581 assert.Contains(t, dom.html, "Saved performance settings")634 assert.Contains(t, dom.html, "Saved performance settings")
582 635
636+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
637+ Action: configActionSavePerformance,
638+ Data: map[string]string{"profile": "default"},
639+ }))
640+ assert.Equal(t, "default", service.savedConfig.Performance.Profile)
641+ assert.Contains(t, dom.html, "Saved performance settings")
642+
583 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{643 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
584 Action: configActionSetKeybinding,644 Action: configActionSetKeybinding,
585 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},645 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
586 }))646 }))
587 assert.True(t, service.calledSet)647 assert.True(t, service.calledSet)
588 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)648 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
649+ assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
589 assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")650 assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")
590 651
591 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{652 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -594,6 +655,8 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
594 }))655 }))
595 assert.True(t, service.calledReset)656 assert.True(t, service.calledReset)
596 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)657 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
658+ assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
659+ assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
597 assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")660 assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")
598 661
599 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))662 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
@@ -601,6 +664,27 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
601 assert.Contains(t, dom.html, "Reset all keybindings to defaults")664 assert.Contains(t, dom.html, "Reset all keybindings to defaults")
602 }665 }
603 666
667+func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
668+ t.Parallel()
669+
670+ service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()}
671+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
672+
673+ err := app.handleConfigAction(context.Background(), DOMAction{
674+ Action: configActionSaveSearch,
675+ Data: map[string]string{"default_search_engine": "https://example.com/search"},
676+ })
677+ require.Error(t, err)
678+ assert.False(t, service.calledSave)
679+
680+ err = app.handleConfigAction(context.Background(), DOMAction{
681+ Action: configActionCreateSearchShortcut,
682+ Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"},
683+ })
684+ require.Error(t, err)
685+ assert.False(t, service.calledSave)
686+}
687+
604 func testConfigPayload() port.SystemviewConfigPayload {688 func testConfigPayload() port.SystemviewConfigPayload {
605 return port.SystemviewConfigPayload{689 return port.SystemviewConfigPayload{
606 EngineType: "webkit",690 EngineType: "webkit",
@@ -639,9 +723,9 @@ type fakeDOM struct {
639 html string723 html string
640 }724 }
641 725
642-func (d *fakeDOM) Mount(html string) error {726+func (d *fakeDOM) Mount(markup string) error {
643 d.mounted = true727 d.mounted = true
644- d.html = html728+ d.html = markup
645 return nil729 return nil
646 }730 }
647 731
@@ -660,6 +744,15 @@ func (d *fakeActionDOM) Release() {
660 d.released = true744 d.released = true
661 }745 }
662 746
747+type failingActionDOM struct {
748+ fakeDOM
749+ bindErr error
750+}
751+
752+func (d *failingActionDOM) BindActions(DOMActionHandler) error {
753+ return d.bindErr
754+}
755+
663 // Handwritten fake to capture history state for stateful render assertions.756 // Handwritten fake to capture history state for stateful render assertions.
664 type fakeHistoryService struct {757 type fakeHistoryService struct {
665 called bool758 called bool
@@ -742,6 +835,7 @@ type fakeFavoritesService struct {
742 updatedFavorite port.FavoriteUpdateInput835 updatedFavorite port.FavoriteUpdateInput
743 deletedFavorite int64836 deletedFavorite int64
744 createdFolder string837 createdFolder string
838+ createdFolderIcon string
745 updatedFolderID int64839 updatedFolderID int64
746 deletedFolderID int64840 deletedFolderID int64
747 createdTag string841 createdTag string
@@ -787,9 +881,10 @@ func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error {
787 881
788 func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }882 func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
789 883
790-func (s *fakeFavoritesService) CreateFolder(_ context.Context, name string, _ *int64) (*entity.Folder, error) {884+func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) {
791 s.createdFolder = name885 s.createdFolder = name
792- return &entity.Folder{ID: 77, Name: name}, nil886+ s.createdFolderIcon = icon
887+ return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
793 }888 }
794 889
795 func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {890 func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
@@ -897,3 +992,127 @@ func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
897 s.calledResetAll = true992 s.calledResetAll = true
898 return nil993 return nil
899 }994 }
995+
996+func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) {
997+ app := NewApp(Dependencies{Favorites: &fakeFavoritesService{}})
998+
999+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1000+ Action: favoriteActionCreate,
1001+ Data: map[string]string{"url": " ", "title": "Blank"},
1002+ })
1003+ require.Error(t, err)
1004+ assert.Contains(t, err.Error(), "favorite URL is required")
1005+
1006+ err = app.handleFavoriteAction(context.Background(), DOMAction{
1007+ Action: favoriteActionCreate,
1008+ Data: map[string]string{"url": "example.com", "title": "Relative"},
1009+ })
1010+ require.Error(t, err)
1011+ assert.Contains(t, err.Error(), "favorite URL must be absolute")
1012+
1013+ err = app.handleFavoriteAction(context.Background(), DOMAction{
1014+ Action: favoriteActionCreate,
1015+ Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"},
1016+ })
1017+ require.Error(t, err)
1018+ assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme")
1019+}
1020+
1021+func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1022+ favorites := &fakeFavoritesService{}
1023+ app := NewApp(Dependencies{Favorites: favorites})
1024+
1025+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1026+ Action: favoriteActionCreate,
1027+ Data: map[string]string{"url": "dumb:history", "title": "History"},
1028+ }))
1029+ assert.Equal(t, "dumb://history", favorites.createdFavorite.URL)
1030+
1031+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1032+ Action: favoriteActionCreate,
1033+ Data: map[string]string{"url": "dumb://config", "title": "Config"},
1034+ }))
1035+ assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1036+}
1037+
1038+func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1039+ favorites := &fakeFavoritesService{}
1040+ app := NewApp(Dependencies{Favorites: favorites})
1041+
1042+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1043+ Action: tagActionAssign,
1044+ Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1045+ }))
1046+ assert.Equal(t, int64(42), favorites.assignedFavoriteID)
1047+ assert.Equal(t, int64(7), favorites.assignedTagID)
1048+
1049+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1050+ Action: tagActionRemove,
1051+ Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1052+ }))
1053+ assert.Equal(t, int64(42), favorites.removedFavoriteID)
1054+ assert.Equal(t, int64(7), favorites.removedTagID)
1055+}
1056+
1057+func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1058+ dom := &fakeDOM{}
1059+ history := &fakeHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}}
1060+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
1061+ app.historyDomainFilter = "example.com"
1062+
1063+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
1064+ Action: historyActionSearch,
1065+ Data: map[string]string{"query": "search"},
1066+ }))
1067+
1068+ assert.Equal(t, "search", app.historyQuery)
1069+ assert.Empty(t, app.historyDomainFilter)
1070+ assert.True(t, history.searchCalled)
1071+ assert.False(t, history.domainCalled)
1072+}
1073+
1074+func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1075+ app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})
1076+
1077+ err := app.RunWithContext(nil)
1078+
1079+ require.ErrorContains(t, err, "context is nil")
1080+}
1081+
1082+func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) {
1083+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
1084+ app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
1085+
1086+ err := app.RunWithContext(context.Background())
1087+
1088+ require.ErrorContains(t, err, "DOM not configured")
1089+ assert.False(t, history.called)
1090+}
1091+
1092+func TestHandleActionsRejectUnknownAction(t *testing.T) {
1093+ app := NewApp(Dependencies{
1094+ History: &fakeHistoryService{},
1095+ Favorites: &fakeFavoritesService{},
1096+ Config: &fakeConfigService{current: testConfigPayload()},
1097+ })
1098+
1099+ require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action")
1100+ require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action")
1101+ require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action")
1102+}
1103+
1104+func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) {
1105+ service := &fakeConfigService{current: testConfigPayload()}
1106+ app := NewApp(Dependencies{Config: service})
1107+
1108+ err := app.handleConfigAction(context.Background(), DOMAction{
1109+ Action: configActionSavePerformance,
1110+ Data: map[string]string{
1111+ "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2",
1112+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
1113+ },
1114+ })
1115+
1116+ require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8")
1117+ assert.False(t, service.calledSave)
1118+}
M internal/ui/systemviews/components.templ +2 -2
@@ -1,7 +1,7 @@
1 package systemviews1 package systemviews
2 2
3 templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) {3 templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) {
4- <div class={ appRootClass(theme) } data-route={ string(page.route) }4+ <div class={ appRootClass(theme) } data-route={ string(page.route) } data-page-title={ pageDocumentTitle(page) }
5 if theme.InlineVars != "" {5 if theme.InlineVars != "" {
6 style={ theme.InlineVars }6 style={ theme.InlineVars }
7 }7 }
@@ -38,7 +38,7 @@ templ Alert(kind string, message string) {
38 }38 }
39 }39 }
40 40
41-templ List(emptyMessage string) {41+templ List() {
42 <ul class="sv-list">42 <ul class="sv-list">
43 { children... }43 { children... }
44 </ul>44 </ul>
M internal/ui/systemviews/components_templ.go +133 -120 generated file: collapsed
M internal/ui/systemviews/config.templ +17 -15
@@ -47,13 +47,13 @@ templ AppearanceForm(config port.SystemviewConfigPayload) {
47 templ PaletteFields(title string, prefix string, palette port.ColorPalette) {47 templ PaletteFields(title string, prefix string, palette port.ColorPalette) {
48 <fieldset class="sv-fieldset">48 <fieldset class="sv-fieldset">
49 <legend>{ title }</legend>49 <legend>{ title }</legend>
50- <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>50+ <label><span>Background</span><input name={ prefix + "_background" } type="color" value={ palette.Background } required/></label>
51- <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>51+ <label><span>Surface</span><input name={ prefix + "_surface" } type="color" value={ palette.Surface } required/></label>
52- <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>52+ <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="color" value={ palette.SurfaceVariant } required/></label>
53- <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>53+ <label><span>Text</span><input name={ prefix + "_text" } type="color" value={ palette.Text } required/></label>
54- <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>54+ <label><span>Muted</span><input name={ prefix + "_muted" } type="color" value={ palette.Muted } required/></label>
55- <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>55+ <label><span>Accent</span><input name={ prefix + "_accent" } type="color" value={ palette.Accent } required/></label>
56- <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label>56+ <label><span>Border</span><input name={ prefix + "_border" } type="color" value={ palette.Border } required/></label>
57 </fieldset>57 </fieldset>
58 }58 }
59 59
@@ -67,7 +67,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) {
67 <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>67 <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>
68 <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">68 <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
69 <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>69 <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
70- <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label>70+ <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label>
71 <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>71 <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
72 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>72 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
73 </form>73 </form>
@@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
89 <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">89 <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">
90 <input type="hidden" name="key" value={ shortcut.Key }/>90 <input type="hidden" name="key" value={ shortcut.Key }/>
91 <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>91 <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
92- <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label>92+ <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label>
93 <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>93 <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>
94 <button class="sv-button sv-button-secondary" type="submit">Save</button>94 <button class="sv-button sv-button-secondary" type="submit">Save</button>
95 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>95 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>
@@ -98,6 +98,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
98 }98 }
99 99
100 templ PerformanceSettings(config port.SystemviewConfigPayload) {100 templ PerformanceSettings(config port.SystemviewConfigPayload) {
101+ {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
101 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">102 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
102 <label><span>Profile</span><select name="profile">103 <label><span>Profile</span><select name="profile">
103 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>104 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
@@ -105,11 +106,12 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) {
105 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>106 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
106 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>107 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
107 </select></label>108 </select></label>
108- <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label>109+ <p class="sv-meta">Choose Custom to edit individual performance values.</p>
109- <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label>110+ <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile }/></label>
110- <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label>111+ <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile }/></label>
111- <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label>112+ <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label>
112- <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label>113+ <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label>
114+ <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label>
113 <button class="sv-button" type="submit">Save performance</button>115 <button class="sv-button" type="submit">Save performance</button>
114 </form>116 </form>
115 <div class="sv-stat-grid">117 <div class="sv-stat-grid">
@@ -183,7 +185,7 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding
183 <form class="sv-inline-form" data-sv-action="config.keybinding.set">185 <form class="sv-inline-form" data-sv-action="config.keybinding.set">
184 <input type="hidden" name="mode" value={ group.Mode }/>186 <input type="hidden" name="mode" value={ group.Mode }/>
185 <input type="hidden" name="action" value={ binding.Action }/>187 <input type="hidden" name="action" value={ binding.Action }/>
186- <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label>188+ <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label>
187 <button class="sv-button sv-button-secondary" type="submit">Save</button>189 <button class="sv-button sv-button-secondary" type="submit">Save</button>
188 <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button>190 <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button>
189 </form>191 </form>
M internal/ui/systemviews/config_actions.go +154 -31
@@ -3,26 +3,39 @@ package systemviews
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
7+ "net/url"
6 "strconv"8 "strconv"
7 "strings"9 "strings"
10+ "sync/atomic"
8 11
9 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
10 )13 )
11 14
12 const (15 const (
13- configActionSaveAppearance = "config.appearance.save"16+ configActionSaveAppearance = "config.appearance.save"
14- configActionResetAppearance = "config.appearance.reset"17+ configActionResetAppearance = "config.appearance.reset"
15- configActionSaveSearch = "config.search.save"18+ configActionSaveSearch = "config.search.save"
16- configActionCreateSearchShortcut = "config.searchShortcut.create"19+ configActionCreateSearchShortcut = "config.searchShortcut.create"
17- configActionUpdateSearchShortcut = "config.searchShortcut.update"20+ configActionUpdateSearchShortcut = "config.searchShortcut.update"
18- configActionDeleteSearchShortcut = "config.searchShortcut.delete"21+ configActionDeleteSearchShortcut = "config.searchShortcut.delete"
19- configActionSavePerformance = "config.performance.save"22+ configActionSavePerformance = "config.performance.save"
20- configActionSetKeybinding = "config.keybinding.set"23+ configActionSetKeybinding = "config.keybinding.set"
21- configActionResetKeybinding = "config.keybinding.reset"24+ configActionResetKeybinding = "config.keybinding.reset"
22- configActionResetAllKeybindings = "config.keybinding.resetAll"25+ configActionResetAllKeybindings = "config.keybinding.resetAll"
23- configRequestIDPrefix = "systemviews-config"26+ configRequestIDPrefix = "systemviews-config"
27+ maxCustomPerformanceThreads = 8
28+ maxCustomPerformanceWebMemoryMB = 16384
29+ maxCustomPerformanceNetworkMemoryMB = 4096
30+ maxCustomPerformancePrewarm = 20
24 )31 )
25 32
33+var configRequestCounter atomic.Uint64
34+
35+func nextConfigRequestID() string {
36+ return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1))
37+}
38+
26 func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {39 func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
27 if a.deps.Config == nil {40 if a.deps.Config == nil {
28 return fmt.Errorf("config service not configured")41 return fmt.Errorf("config service not configured")
@@ -52,8 +65,10 @@ func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
52 return err65 return err
53 }66 }
54 a.configNotice = "Reset all keybindings to defaults"67 a.configNotice = "Reset all keybindings to defaults"
68+ return nil
69+ default:
70+ return fmt.Errorf("unknown config action: %q", event.Action)
55 }71 }
56- return nil
57 }72 }
58 73
59 func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {74 func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
@@ -70,14 +85,28 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string)
70 if err != nil {85 if err != nil {
71 return err86 return err
72 }87 }
88+ if fontSize <= 0 {
89+ return fmt.Errorf("default font size must be positive")
90+ }
91+ if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) {
92+ return fmt.Errorf("UI scale must be a finite positive number")
93+ }
73 94
74 cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])95 cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
75 cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])96 cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
76 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])97 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
77 cfg.Appearance.DefaultFontSize = fontSize98 cfg.Appearance.DefaultFontSize = fontSize
78 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])99 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
79- cfg.Appearance.LightPalette = paletteFromForm(data, "light")100+ lightPalette, err := paletteFromForm(data, "light")
80- cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")101+ if err != nil {
102+ return err
103+ }
104+ darkPalette, err := paletteFromForm(data, "dark")
105+ if err != nil {
106+ return err
107+ }
108+ cfg.Appearance.LightPalette = lightPalette
109+ cfg.Appearance.DarkPalette = darkPalette
81 cfg.DefaultUIScale = uiScale110 cfg.DefaultUIScale = uiScale
82 111
83 if err := a.saveEditableConfig(ctx, cfg); err != nil {112 if err := a.saveEditableConfig(ctx, cfg); err != nil {
@@ -110,7 +139,11 @@ func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) erro
110 if err != nil {139 if err != nil {
111 return err140 return err
112 }141 }
113- cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"])142+ defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine")
143+ if err != nil {
144+ return err
145+ }
146+ cfg.DefaultSearchEngine = defaultSearchEngine
114 if err := a.saveEditableConfig(ctx, cfg); err != nil {147 if err := a.saveEditableConfig(ctx, cfg); err != nil {
115 return err148 return err
116 }149 }
@@ -127,6 +160,9 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string)
127 if key == "" {160 if key == "" {
128 return fmt.Errorf("search shortcut key is required")161 return fmt.Errorf("search shortcut key is required")
129 }162 }
163+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
164+ return err
165+ }
130 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)166 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
131 if _, exists := cfg.SearchShortcuts[key]; exists {167 if _, exists := cfg.SearchShortcuts[key]; exists {
132 return fmt.Errorf("search shortcut %q already exists", key)168 return fmt.Errorf("search shortcut %q already exists", key)
@@ -152,6 +188,9 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string)
152 if newKey == "" {188 if newKey == "" {
153 return fmt.Errorf("new search shortcut key is required")189 return fmt.Errorf("new search shortcut key is required")
154 }190 }
191+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
192+ return err
193+ }
155 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)194 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
156 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {195 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
157 return fmt.Errorf("search shortcut %q not found", oldKey)196 return fmt.Errorf("search shortcut %q not found", oldKey)
@@ -197,6 +236,15 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
197 return err236 return err
198 }237 }
199 profile := strings.TrimSpace(data["profile"])238 profile := strings.TrimSpace(data["profile"])
239+ cfg.Performance.Profile = profile
240+ if profile != "custom" {
241+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
242+ return err
243+ }
244+ a.configNotice = "Saved performance settings. Restart may be required."
245+ return nil
246+ }
247+
200 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")248 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
201 if err != nil {249 if err != nil {
202 return err250 return err
@@ -217,8 +265,22 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
217 if err != nil {265 if err != nil {
218 return err266 return err
219 }267 }
268+ if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil {
269+ return err
270+ }
271+ if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil {
272+ return err
273+ }
274+ if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil {
275+ return err
276+ }
277+ if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil {
278+ return err
279+ }
280+ if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil {
281+ return err
282+ }
220 283
221- cfg.Performance.Profile = profile
222 cfg.Performance.Custom = port.WebUICustomPerformanceConfig{284 cfg.Performance.Custom = port.WebUICustomPerformanceConfig{
223 SkiaCPUThreads: skiaCPU,285 SkiaCPUThreads: skiaCPU,
224 SkiaGPUThreads: skiaGPU,286 SkiaGPUThreads: skiaGPU,
@@ -244,7 +306,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e
244 return err306 return err
245 }307 }
246 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{308 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
247- RequestID: configRequestIDPrefix,309+ RequestID: nextConfigRequestID(),
248 Mode: mode,310 Mode: mode,
249 Action: action,311 Action: action,
250 Keys: keys,312 Keys: keys,
@@ -270,7 +332,7 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
270 return err332 return err
271 }333 }
272 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{334 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
273- RequestID: configRequestIDPrefix,335+ RequestID: nextConfigRequestID(),
274 Mode: mode,336 Mode: mode,
275 Action: action,337 Action: action,
276 }); err != nil {338 }); err != nil {
@@ -344,16 +406,57 @@ func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]p
344 return clone406 return clone
345 }407 }
346 408
347-func paletteFromForm(data map[string]string, prefix string) port.ColorPalette {409+func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette, error) {
348- return port.ColorPalette{410+ color := func(field string) (string, error) {
349- Background: strings.TrimSpace(data[prefix+"_background"]),411+ value := strings.TrimSpace(data[prefix+"_"+field])
350- Surface: strings.TrimSpace(data[prefix+"_surface"]),412+ if value == "" || isValidHexColor(value) {
351- SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]),413+ return value, nil
352- Text: strings.TrimSpace(data[prefix+"_text"]),414+ }
353- Muted: strings.TrimSpace(data[prefix+"_muted"]),415+ return "", fmt.Errorf("%s_%s must be a hex color", prefix, field)
354- Accent: strings.TrimSpace(data[prefix+"_accent"]),416+ }
355- Border: strings.TrimSpace(data[prefix+"_border"]),417+
418+ background, err := color("background")
419+ if err != nil {
420+ return port.ColorPalette{}, err
421+ }
422+ surface, err := color("surface")
423+ if err != nil {
424+ return port.ColorPalette{}, err
425+ }
426+ surfaceVariant, err := color("surface_variant")
427+ if err != nil {
428+ return port.ColorPalette{}, err
429+ }
430+ text, err := color("text")
431+ if err != nil {
432+ return port.ColorPalette{}, err
433+ }
434+ muted, err := color("muted")
435+ if err != nil {
436+ return port.ColorPalette{}, err
437+ }
438+ accent, err := color("accent")
439+ if err != nil {
440+ return port.ColorPalette{}, err
356 }441 }
442+ border, err := color("border")
443+ if err != nil {
444+ return port.ColorPalette{}, err
445+ }
446+
447+ return port.ColorPalette{
448+ Background: background,
449+ Surface: surface,
450+ SurfaceVariant: surfaceVariant,
451+ Text: text,
452+ Muted: muted,
453+ Accent: accent,
454+ Border: border,
455+ }, nil
456+}
457+
458+func isValidHexColor(value string) bool {
459+ return isHexColor(strings.TrimSpace(value))
357 }460 }
358 461
359 func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) {462 func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) {
@@ -363,6 +466,21 @@ func searchShortcutFromForm(data map[string]string, keyField string) (string, po
363 }466 }
364 }467 }
365 468
469+func requireSearchURLTemplate(raw, label string) (string, error) {
470+ value := strings.TrimSpace(raw)
471+ if value == "" {
472+ return "", fmt.Errorf("%s is required", label)
473+ }
474+ if !strings.Contains(value, "%s") {
475+ return "", fmt.Errorf("%s must include %%s placeholder", label)
476+ }
477+ parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
478+ if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
479+ return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
480+ }
481+ return value, nil
482+}
483+
366 func parseConfigInt(raw, label string) (int, error) {484 func parseConfigInt(raw, label string) (int, error) {
367 value, err := strconv.Atoi(strings.TrimSpace(raw))485 value, err := strconv.Atoi(strings.TrimSpace(raw))
368 if err != nil {486 if err != nil {
@@ -379,15 +497,20 @@ func parseConfigFloat(raw, label string) (float64, error) {
379 return value, nil497 return value, nil
380 }498 }
381 499
500+func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
501+ if value < minValue || value > maxValue {
502+ return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
503+ }
504+ return nil
505+}
506+
382 func parseKeyList(raw string) []string {507 func parseKeyList(raw string) []string {
383 raw = strings.TrimSpace(raw)508 raw = strings.TrimSpace(raw)
384 if raw == "" {509 if raw == "" {
385 return nil510 return nil
386 }511 }
387- parts := strings.Split(raw, ",")512+ raw = strings.ReplaceAll(raw, ",", " ")
388- if len(parts) == 1 {513+ parts := strings.Fields(raw)
389- parts = strings.Fields(raw)
390- }
391 keys := make([]string, 0, len(parts))514 keys := make([]string, 0, len(parts))
392 for _, part := range parts {515 for _, part := range parts {
393 key := strings.TrimSpace(part)516 key := strings.TrimSpace(part)
M internal/ui/systemviews/config_templ.go +119 -68 generated file: collapsed
M internal/ui/systemviews/config_view.go +9 -2
@@ -16,6 +16,7 @@ type configRenderData struct {
16 Error string16 Error string
17 }17 }
18 18
19+// maxFlattenDepth caps recursive config flattening to avoid runaway nesting.
19 const maxFlattenDepth = 1620 const maxFlattenDepth = 16
20 21
21 func configHTML(data configRenderData) string {22 func configHTML(data configRenderData) string {
@@ -253,6 +254,9 @@ func formatFlatValue(value reflect.Value) string {
253 case reflect.Complex64, reflect.Complex128:254 case reflect.Complex64, reflect.Complex128:
254 return fmt.Sprint(value.Complex())255 return fmt.Sprint(value.Complex())
255 default:256 default:
257+ if !value.CanInterface() {
258+ return "<unexported>"
259+ }
256 return fmt.Sprint(value.Interface())260 return fmt.Sprint(value.Interface())
257 }261 }
258 }262 }
@@ -275,7 +279,10 @@ func mapKeyString(value reflect.Value) string {
275 if value.Kind() == reflect.String {279 if value.Kind() == reflect.String {
276 return value.String()280 return value.String()
277 }281 }
278- return fmt.Sprint(value.Interface())282+ if value.CanInterface() {
283+ return fmt.Sprint(value.Interface())
284+ }
285+ return formatFlatValue(value)
279 }286 }
280 287
281 func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {288 func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {
@@ -344,5 +351,5 @@ func plural(count int, singular string) string {
344 if count == 1 {351 if count == 1 {
345 return singular352 return singular
346 }353 }
347- return strings.TrimSpace(singular + "s")354+ return singular + "s"
348 }355 }
M internal/ui/systemviews/dom.go +1 -1
@@ -6,7 +6,7 @@ import (
6 )6 )
7 7
8 type DOM interface {8 type DOM interface {
9- Mount(html string) error9+ Mount(markup string) error
10 }10 }
11 11
12 // DOMAction is a browser event delegated from the mounted systemview DOM.12 // DOMAction is a browser event delegated from the mounted systemview DOM.
M internal/ui/systemviews/dom_js.go +115 -11
@@ -28,21 +28,79 @@ func NewDOM() DOM {
28 }28 }
29 29
30 target := doc.Call("getElementById", "app")30 target := doc.Call("getElementById", "app")
31- if !target.Truthy() {
32- target = doc.Get("body")
33- }
34-
35 return &browserDOM{document: doc, target: target}31 return &browserDOM{document: doc, target: target}
36 }32 }
37 33
38-func (d *browserDOM) Mount(html string) error {34+func (d *browserDOM) Mount(markup string) error {
39 if d == nil || !d.target.Truthy() {35 if d == nil || !d.target.Truthy() {
40- return fmt.Errorf("DOM target not available")36+ return fmt.Errorf("DOM mount target #app not found")
41 }37 }
42- d.target.Set("innerHTML", html)38+ d.target.Set("innerHTML", markup)
39+ d.updateDocumentTitle()
40+ d.scheduleAlertDismissal()
43 return nil41 return nil
44 }42 }
45 43
44+func (d *browserDOM) updateDocumentTitle() {
45+ if d == nil || !d.document.Truthy() || !d.target.Truthy() {
46+ return
47+ }
48+ root := d.target.Call("querySelector", "[data-page-title]")
49+ if !root.Truthy() {
50+ return
51+ }
52+ title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String())
53+ if title == "" {
54+ return
55+ }
56+ d.document.Set("title", title)
57+}
58+
59+func (d *browserDOM) scheduleAlertDismissal() {
60+ if d == nil || !d.target.Truthy() {
61+ return
62+ }
63+ alerts := d.target.Call("querySelectorAll", ".sv-alert")
64+ length := alerts.Get("length").Int()
65+ if length == 0 {
66+ return
67+ }
68+ setTimeout := js.Global().Get("setTimeout")
69+ if !setTimeout.Truthy() {
70+ return
71+ }
72+ for i := 0; i < length; i++ {
73+ alert := alerts.Index(i)
74+ if !alert.Truthy() {
75+ continue
76+ }
77+ scheduleAlertRemoval(setTimeout, alert)
78+ }
79+}
80+
81+func scheduleAlertRemoval(setTimeout, alert js.Value) {
82+ var fadeFn js.Func
83+ fadeFn = js.FuncOf(func(js.Value, []js.Value) any {
84+ defer fadeFn.Release()
85+ if !alert.Truthy() || !alert.Get("classList").Truthy() {
86+ return nil
87+ }
88+ alert.Get("classList").Call("add", "sv-alert-dismiss")
89+
90+ var removeFn js.Func
91+ removeFn = js.FuncOf(func(js.Value, []js.Value) any {
92+ defer removeFn.Release()
93+ if alert.Truthy() && alert.Get("remove").Truthy() {
94+ alert.Call("remove")
95+ }
96+ return nil
97+ })
98+ setTimeout.Invoke(removeFn, 220)
99+ return nil
100+ })
101+ setTimeout.Invoke(fadeFn, 4200)
102+}
103+
46 func (d *browserDOM) BindActions(handler DOMActionHandler) error {104 func (d *browserDOM) BindActions(handler DOMActionHandler) error {
47 if d == nil || !d.target.Truthy() || handler == nil {105 if d == nil || !d.target.Truthy() || handler == nil {
48 return fmt.Errorf("DOM action binding target not available")106 return fmt.Errorf("DOM action binding target not available")
@@ -58,6 +116,15 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
58 if !element.Truthy() {116 if !element.Truthy() {
59 return nil117 return nil
60 }118 }
119+ if strings.EqualFold(element.Get("tagName").String(), "form") {
120+ target := event.Get("target")
121+ if !target.Truthy() {
122+ target = event.Get("srcElement")
123+ }
124+ if !target.Truthy() || !element.Equal(target) {
125+ return nil
126+ }
127+ }
61 action := element.Call("getAttribute", "data-sv-action").String()128 action := element.Call("getAttribute", "data-sv-action").String()
62 if action == "" {129 if action == "" {
63 return nil130 return nil
@@ -84,6 +151,9 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
84 if action == "" {151 if action == "" {
85 return nil152 return nil
86 }153 }
154+ if !confirmAction(form) {
155+ return nil
156+ }
87 event.Call("preventDefault")157 event.Call("preventDefault")
88 data := collectActionData(form)158 data := collectActionData(form)
89 for key, value := range collectFormData(form) {159 for key, value := range collectFormData(form) {
@@ -95,7 +165,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
95 d.addEventBinding(d.target, "submit", submitHandler)165 d.addEventBinding(d.target, "submit", submitHandler)
96 166
97 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {167 keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
98- d.handleKeyboard(firstJSArg(args))168+ event := firstJSArg(args)
169+ if !d.eventTargetInsideMount(event) {
170+ return nil
171+ }
172+ d.handleKeyboard(event)
99 return nil173 return nil
100 })174 })
101 d.addEventBinding(d.document, "keydown", keydownHandler)175 d.addEventBinding(d.document, "keydown", keydownHandler)
@@ -103,6 +177,26 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
103 return nil177 return nil
104 }178 }
105 179
180+func (d *browserDOM) eventTargetInsideMount(event js.Value) bool {
181+ if d == nil || !d.target.Truthy() || !event.Truthy() {
182+ return false
183+ }
184+ target := event.Get("target")
185+ if !target.Truthy() {
186+ target = event.Get("srcElement")
187+ }
188+ if !target.Truthy() {
189+ return false
190+ }
191+ if d.document.Truthy() && target.Equal(d.document) {
192+ return true
193+ }
194+ if strings.EqualFold(target.Get("nodeName").String(), "body") {
195+ return true
196+ }
197+ return d.target.Call("contains", target).Bool()
198+}
199+
106 func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {200 func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
107 if !target.Truthy() {201 if !target.Truthy() {
108 fn.Release()202 fn.Release()
@@ -172,7 +266,13 @@ func collectFormData(form js.Value) map[string]string {
172 if pair.Get("length").Int() < 2 {266 if pair.Get("length").Int() < 2 {
173 continue267 continue
174 }268 }
175- data[pair.Index(0).String()] = pair.Index(1).String()269+ key := pair.Index(0).String()
270+ value := pair.Index(1).String()
271+ if existing := data[key]; existing != "" {
272+ data[key] = existing + "," + value
273+ continue
274+ }
275+ data[key] = value
176 }276 }
177 return data277 return data
178 }278 }
@@ -232,6 +332,8 @@ func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
232 }332 }
233 }333 }
234 case "d", "delete", "backspace":334 case "d", "delete", "backspace":
335+ // Delete the focused row through the same delegated click path as pointer users,
336+ // including the data-sv-confirm guard on the delete button.
235 if row := d.activeHistoryRow(); row.Truthy() {337 if row := d.activeHistoryRow(); row.Truthy() {
236 event.Call("preventDefault")338 event.Call("preventDefault")
237 if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {339 if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
@@ -349,14 +451,16 @@ func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
349 func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {451 func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
350 rows := d.target.Call("querySelectorAll", selector)452 rows := d.target.Call("querySelectorAll", selector)
351 length := rows.Get("length").Int()453 length := rows.Get("length").Int()
352- if length == 0 {454+ if length <= 0 {
353 return js.Value{}455 return js.Value{}
354 }456 }
355 idx := d.activeIndex(datasetKey)457 idx := d.activeIndex(datasetKey)
356 if idx < 0 || idx >= length {458 if idx < 0 || idx >= length {
357 idx = 0459 idx = 0
460+ row := rows.Index(idx)
358 d.target.Get("dataset").Set(datasetKey, "0")461 d.target.Get("dataset").Set(datasetKey, "0")
359- rows.Index(0).Get("classList").Call("add", "sv-focused")462+ row.Get("classList").Call("add", "sv-focused")
463+ return row
360 }464 }
361 return rows.Index(idx)465 return rows.Index(idx)
362 }466 }
M internal/ui/systemviews/favorite_actions.go +72 -4
@@ -3,6 +3,7 @@ package systemviews
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "net/url"
6 "strconv"7 "strconv"
7 "strings"8 "strings"
8 9
@@ -38,10 +39,19 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
38 if err != nil {39 if err != nil {
39 return err40 return err
40 }41 }
42+ favoriteURL, err := validateFavoriteURL(data["url"])
43+ if err != nil {
44+ return err
45+ }
46+ tags, err := optionalTagIDs(data["tags"])
47+ if err != nil {
48+ return err
49+ }
41 favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{50 favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{
42- URL: strings.TrimSpace(data["url"]),51+ URL: favoriteURL,
43 Title: strings.TrimSpace(data["title"]),52 Title: strings.TrimSpace(data["title"]),
44 FolderID: folderID,53 FolderID: folderID,
54+ Tags: tags,
45 })55 })
46 if err != nil {56 if err != nil {
47 return err57 return err
@@ -106,7 +116,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
106 if name == "" {116 if name == "" {
107 return fmt.Errorf("folder name is required")117 return fmt.Errorf("folder name is required")
108 }118 }
109- folder, err := a.deps.Favorites.CreateFolder(ctx, name, nil)119+ folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
110 if err != nil {120 if err != nil {
111 return err121 return err
112 }122 }
@@ -168,11 +178,11 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
168 a.favoriteTagFilter = nil178 a.favoriteTagFilter = nil
169 a.favoritesNotice = "Deleted tag"179 a.favoritesNotice = "Deleted tag"
170 case tagActionAssign, tagActionRemove:180 case tagActionAssign, tagActionRemove:
171- favoriteID, err := parsePositiveInt64(data["favoriteId"], "favorite id")181+ favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
172 if err != nil {182 if err != nil {
173 return err183 return err
174 }184 }
175- tagID, err := parsePositiveInt64(data["tagId"], "tag id")185+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
176 if err != nil {186 if err != nil {
177 return err187 return err
178 }188 }
@@ -187,10 +197,40 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
187 }197 }
188 a.favoritesNotice = "Removed tag"198 a.favoritesNotice = "Removed tag"
189 }199 }
200+ default:
201+ return fmt.Errorf("unknown favorite action: %q", event.Action)
190 }202 }
191 return nil203 return nil
192 }204 }
193 205
206+func validateFavoriteURL(raw string) (string, error) {
207+ favoriteURL := strings.TrimSpace(raw)
208+ if favoriteURL == "" {
209+ return "", fmt.Errorf("favorite URL is required")
210+ }
211+ parsed, err := url.Parse(favoriteURL)
212+ if err != nil || parsed.Scheme == "" {
213+ return "", fmt.Errorf("favorite URL must be absolute")
214+ }
215+ switch strings.ToLower(parsed.Scheme) {
216+ case "http", "https":
217+ if parsed.Host == "" {
218+ return "", fmt.Errorf("favorite URL host is required")
219+ }
220+ case "dumb":
221+ if parsed.Host != "" {
222+ return favoriteURL, nil
223+ }
224+ if parsed.Opaque != "" {
225+ return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
226+ }
227+ return "", fmt.Errorf("favorite URL host is required")
228+ default:
229+ return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
230+ }
231+ return favoriteURL, nil
232+}
233+
194 func firstActionValue(data map[string]string, keys ...string) string {234 func firstActionValue(data map[string]string, keys ...string) string {
195 for _, key := range keys {235 for _, key := range keys {
196 if value, ok := data[key]; ok {236 if value, ok := data[key]; ok {
@@ -208,6 +248,31 @@ func parsePositiveInt64(raw, label string) (int64, error) {
208 return id, nil248 return id, nil
209 }249 }
210 250
251+func optionalTagIDs(raw string) ([]entity.TagID, error) {
252+ raw = strings.TrimSpace(raw)
253+ if raw == "" {
254+ return nil, nil
255+ }
256+ parts := strings.Split(raw, ",")
257+ ids := make([]entity.TagID, 0, len(parts))
258+ seen := make(map[entity.TagID]struct{}, len(parts))
259+ for _, part := range parts {
260+ id, err := parsePositiveInt64(part, "tag id")
261+ if err != nil {
262+ return nil, err
263+ }
264+ tagID := entity.TagID(id)
265+ if _, ok := seen[tagID]; ok {
266+ continue
267+ }
268+ seen[tagID] = struct{}{}
269+ ids = append(ids, tagID)
270+ }
271+ return ids, nil
272+}
273+
274+// optionalFolderID returns nil for empty or "root" input, meaning no folder constraint;
275+// otherwise it parses a positive folder id into a non-nil *entity.FolderID.
211 func optionalFolderID(raw string) (*entity.FolderID, error) {276 func optionalFolderID(raw string) (*entity.FolderID, error) {
212 raw = strings.TrimSpace(raw)277 raw = strings.TrimSpace(raw)
213 if raw == "" || raw == "root" {278 if raw == "" || raw == "root" {
@@ -221,6 +286,8 @@ func optionalFolderID(raw string) (*entity.FolderID, error) {
221 return &folderID, nil286 return &folderID, nil
222 }287 }
223 288
289+// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
290+// favorites in the root folder; other inputs delegate to optionalFolderID.
224 func filterFolderID(raw string) (*entity.FolderID, error) {291 func filterFolderID(raw string) (*entity.FolderID, error) {
225 if strings.TrimSpace(raw) == "root" {292 if strings.TrimSpace(raw) == "root" {
226 root := entity.FolderID(0)293 root := entity.FolderID(0)
@@ -229,6 +296,7 @@ func filterFolderID(raw string) (*entity.FolderID, error) {
229 return optionalFolderID(raw)296 return optionalFolderID(raw)
230 }297 }
231 298
299+// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
232 func optionalShortcut(raw string) (*int, error) {300 func optionalShortcut(raw string) (*int, error) {
233 raw = strings.TrimSpace(raw)301 raw = strings.TrimSpace(raw)
234 if raw == "" {302 if raw == "" {
M internal/ui/systemviews/favorites.templ +23 -4
@@ -62,16 +62,17 @@ templ FavoritesControls(data favoritesRenderData) {
62 <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>62 <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
63 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>63 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64 @FolderSelect("folder_id", data.Folders, nil, "Folder")64 @FolderSelect("folder_id", data.Folders, nil, "Folder")
65+ @FavoriteCreateTagPicker(data.Tags)
65 <button class="sv-button" type="submit">Add favorite</button>66 <button class="sv-button" type="submit">Add favorite</button>
66 </form>67 </form>
67 <div class="sv-filter-panel">68 <div class="sv-filter-panel">
68 <div class="sv-button-row">69 <div class="sv-button-row">
69 <span class="sv-meta">Folders</span>70 <span class="sv-meta">Folders</span>
70- <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>71+ <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>
71- <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>72+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>
72 for _, folder := range data.Folders {73 for _, folder := range data.Folders {
73 if folder != nil {74 if folder != nil {
74- <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button>75+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button>
75 }76 }
76 }77 }
77 </div>78 </div>
@@ -79,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) {
79 <span class="sv-meta">Tags</span>80 <span class="sv-meta">Tags</span>
80 for _, tag := range data.Tags {81 for _, tag := range data.Tags {
81 if tag != nil {82 if tag != nil {
82- <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>83+ <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
83 <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }84 <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }
84 </button>85 </button>
85 }86 }
@@ -89,6 +90,24 @@ templ FavoritesControls(data favoritesRenderData) {
89 <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>90 <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>
90 }91 }
91 92
93+templ FavoriteCreateTagPicker(tags []*entity.Tag) {
94+ if len(tags) > 0 {
95+ <fieldset class="sv-tag-picker">
96+ <legend>Tags</legend>
97+ <div class="sv-tag-controls">
98+ for _, tag := range tags {
99+ if tag != nil {
100+ <label class="sv-tag-toggle">
101+ <input type="checkbox" name="tags" value={ fmt.Sprintf("%d", tag.ID) }/>
102+ <span><span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }</span>
103+ </label>
104+ }
105+ }
106+ </div>
107+ </fieldset>
108+ }
109+}
110+
92 templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {111 templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
93 <li class="sv-list-row">112 <li class="sv-list-row">
94 <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>113 <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
M internal/ui/systemviews/favorites_templ.go +393 -255 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +17 -3
@@ -21,6 +21,20 @@ func favoritesHTML(data favoritesRenderData) string {
21 return mustRenderComponent(FavoritesView(data))21 return mustRenderComponent(FavoritesView(data))
22 }22 }
23 23
24+func favoritesDocumentTitle(data favoritesRenderData) string {
25+ count := len(data.Favorites)
26+ if data.FolderFilter != nil || data.TagFilter != nil {
27+ if count == 1 {
28+ return "Favorites — 1 filtered bookmark"
29+ }
30+ return fmt.Sprintf("Favorites — %d filtered bookmarks", count)
31+ }
32+ if count == 1 {
33+ return "Favorites — 1 bookmark"
34+ }
35+ return fmt.Sprintf("Favorites — %d bookmarks", count)
36+}
37+
24 func favoritesSummary(data favoritesRenderData) string {38 func favoritesSummary(data favoritesRenderData) string {
25 return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))39 return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
26 }40 }
@@ -68,7 +82,7 @@ func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) strin
68 if favorite == nil {82 if favorite == nil {
69 return ""83 return ""
70 }84 }
71- parts := []string{}85+ parts := make([]string, 0, 2)
72 if favorite.ShortcutKey != nil {86 if favorite.ShortcutKey != nil {
73 parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))87 parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
74 }88 }
@@ -96,9 +110,9 @@ func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string {
96 }110 }
97 111
98 func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {112 func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
99- classes := "sv-button sv-button-secondary"113+ classes := "sv-button sv-button-secondary sv-tag-button"
100 if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {114 if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
101- classes += " sv-button-active"115+ classes += " sv-button-active sv-tag-button-active"
102 }116 }
103 return classes117 return classes
104 }118 }
M internal/ui/systemviews/history_actions.go +30 -3
@@ -25,8 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
25 if a == nil {25 if a == nil {
26 return fmt.Errorf("app is nil")26 return fmt.Errorf("app is nil")
27 }27 }
28- a.mu.Lock()28+ a.lockState()
29- defer a.mu.Unlock()29+ defer a.unlockState()
30 if a.currentRoute == RouteUnknown || a.currentRoute == "" {30 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
31 a.currentRoute = ParseRoute(a.deps.LocationURI)31 a.currentRoute = ParseRoute(a.deps.LocationURI)
32 }32 }
@@ -40,6 +40,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
40 }40 }
41 if err := a.loadHistoryRoute(ctx); err != nil {41 if err := a.loadHistoryRoute(ctx); err != nil {
42 a.renderRouteError(err)42 a.renderRouteError(err)
43+ _ = a.mountRenderedHTML()
44+ return err
43 }45 }
44 return a.mountRenderedHTML()46 return a.mountRenderedHTML()
45 case RouteFavorites:47 case RouteFavorites:
@@ -50,6 +52,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
50 }52 }
51 if err := a.loadFavoritesRoute(ctx); err != nil {53 if err := a.loadFavoritesRoute(ctx); err != nil {
52 a.renderRouteError(err)54 a.renderRouteError(err)
55+ _ = a.mountRenderedHTML()
56+ return err
53 }57 }
54 return a.mountRenderedHTML()58 return a.mountRenderedHTML()
55 case RouteConfig:59 case RouteConfig:
@@ -61,6 +65,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
61 a.loadShellTheme(ctx)65 a.loadShellTheme(ctx)
62 if err := a.loadConfigRoute(ctx); err != nil {66 if err := a.loadConfigRoute(ctx); err != nil {
63 a.renderRouteError(err)67 a.renderRouteError(err)
68+ _ = a.mountRenderedHTML()
69+ return err
64 }70 }
65 return a.mountRenderedHTML()71 return a.mountRenderedHTML()
66 default:72 default:
@@ -77,6 +83,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
77 switch event.Action {83 switch event.Action {
78 case historyActionSearch:84 case historyActionSearch:
79 a.historyQuery = strings.TrimSpace(data["query"])85 a.historyQuery = strings.TrimSpace(data["query"])
86+ a.historyDomainFilter = ""
80 a.historyOffset = 087 a.historyOffset = 0
81 a.historyNotice = ""88 a.historyNotice = ""
82 case historyActionClear:89 case historyActionClear:
@@ -90,6 +97,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
90 return fmt.Errorf("domain is required")97 return fmt.Errorf("domain is required")
91 }98 }
92 a.historyDomainFilter = domain99 a.historyDomainFilter = domain
100+ a.historyQuery = ""
93 a.historyOffset = 0101 a.historyOffset = 0
94 a.historyNotice = ""102 a.historyNotice = ""
95 case historyActionClearDomain:103 case historyActionClearDomain:
@@ -111,12 +119,16 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
111 if err := a.deps.History.DeleteEntry(ctx, id); err != nil {119 if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
112 return err120 return err
113 }121 }
122+ a.historyOffset = 0
114 a.historyNotice = "Deleted history entry"123 a.historyNotice = "Deleted history entry"
115 case historyActionDeleteRange:124 case historyActionDeleteRange:
116 rangeID := strings.TrimSpace(data["range"])125 rangeID := strings.TrimSpace(data["range"])
117 if rangeID == "" {126 if rangeID == "" {
118 return fmt.Errorf("history range is required")127 return fmt.Errorf("history range is required")
119 }128 }
129+ if !isKnownHistoryCleanupRange(rangeID) {
130+ return fmt.Errorf("invalid history range")
131+ }
120 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {132 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
121 return err133 return err
122 }134 }
@@ -135,15 +147,30 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
135 }147 }
136 a.historyOffset = 0148 a.historyOffset = 0
137 a.historyNotice = "Deleted history for " + domain149 a.historyNotice = "Deleted history for " + domain
150+ default:
151+ return fmt.Errorf("unknown history action: %q", event.Action)
138 }152 }
139 return nil153 return nil
140 }154 }
141 155
142 func (a *App) mountRenderedHTML() error {156 func (a *App) mountRenderedHTML() error {
157+ return a.mountHTML(a.renderedHTML)
158+}
159+
160+func (a *App) mountHTML(html string) error {
143 if a.deps.DOM == nil {161 if a.deps.DOM == nil {
144 return nil162 return nil
145 }163 }
146- return a.deps.DOM.Mount(a.renderedHTML)164+ return a.deps.DOM.Mount(html)
165+}
166+
167+func isKnownHistoryCleanupRange(rangeID string) bool {
168+ for _, item := range historyCleanupItems() {
169+ if item.RangeID == rangeID {
170+ return true
171+ }
172+ }
173+ return false
147 }174 }
148 175
149 func historyRangeNotice(rangeID string) string {176 func historyRangeNotice(rangeID string) string {
M internal/ui/systemviews/history_view.go +40 -29
@@ -38,11 +38,41 @@ func historyHTML(data historyRenderData) string {
38 return mustRenderComponent(HistoryView(data))38 return mustRenderComponent(HistoryView(data))
39 }39 }
40 40
41+func historyDocumentTitle(data historyRenderData) string {
42+ if query := strings.TrimSpace(data.Query); query != "" {
43+ return "History — search: " + truncateTitle(query, 48)
44+ }
45+ if domain := strings.TrimSpace(data.DomainFilter); domain != "" {
46+ label := browserurl.DisplayDomain(domain)
47+ if label == "" {
48+ label = domain
49+ }
50+ return "History — " + label
51+ }
52+
53+ entries := int64(countHistoryEntries(data.Entries))
54+ if data.Analytics != nil {
55+ entries = data.Analytics.TotalEntries
56+ }
57+ if entries == 1 {
58+ return "History — 1 entry"
59+ }
60+ return fmt.Sprintf("History — %d entries", entries)
61+}
62+
63+func truncateTitle(value string, maxRunes int) string {
64+ runes := []rune(strings.TrimSpace(value))
65+ if maxRunes <= 0 || len(runes) <= maxRunes {
66+ return string(runes)
67+ }
68+ return string(runes[:maxRunes]) + "…"
69+}
70+
41 var historyCleanupRanges = []historyCleanupItem{71 var historyCleanupRanges = []historyCleanupItem{
42- {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?", Notice: "Deleted history from the last hour"},72+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"},
43- {Label: "Today", RangeID: "day", Confirm: "Delete history for today?", Notice: "Deleted history from today"},73+ {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"},
44- {Label: "Week", RangeID: "week", Confirm: "Delete history for week?", Notice: "Deleted history from this week"},74+ {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"},
45- {Label: "Month", RangeID: "month", Confirm: "Delete history for month?", Notice: "Deleted history from this month"},75+ {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"},
46 {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},76 {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
47 }77 }
48 78
@@ -103,11 +133,6 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i
103 visits = sumHistoryVisits(data.Entries)133 visits = sumHistoryVisits(data.Entries)
104 uniqueDays = countUniqueHistoryDays(data.Entries)134 uniqueDays = countUniqueHistoryDays(data.Entries)
105 135
106- if data.Analytics != nil {
107- entries = data.Analytics.TotalEntries
108- visits = data.Analytics.TotalVisits
109- uniqueDays = data.Analytics.UniqueDays
110- }
111 return entries, visits, uniqueDays136 return entries, visits, uniqueDays
112 }137 }
113 138
@@ -121,11 +146,12 @@ func historyTimelineEmptyMessage(data historyRenderData) string {
121 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {146 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
122 groups := make([]historyTimelineGroup, 0)147 groups := make([]historyTimelineGroup, 0)
123 indexByDate := map[string]int{}148 indexByDate := map[string]int{}
149+ now := time.Now().Local()
124 for _, entry := range entries {150 for _, entry := range entries {
125 if entry == nil {151 if entry == nil {
126 continue152 continue
127 }153 }
128- dateKey, label := historyDateKeyAndLabel(entry.LastVisited)154+ dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now)
129 idx, ok := indexByDate[dateKey]155 idx, ok := indexByDate[dateKey]
130 if !ok {156 if !ok {
131 idx = len(groups)157 idx = len(groups)
@@ -137,14 +163,14 @@ func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup
137 return groups163 return groups
138 }164 }
139 165
140-func historyDateKeyAndLabel(ts time.Time) (string, string) {166+func historyDateKeyAndLabel(ts, now time.Time) (string, string) {
141 if ts.IsZero() {167 if ts.IsZero() {
142 return "unknown", "Unknown date"168 return "unknown", "Unknown date"
143 }169 }
144 170
145 local := ts.Local()171 local := ts.Local()
146 dateKey := local.Format("2006-01-02")172 dateKey := local.Format("2006-01-02")
147- today := time.Now().Local()173+ today := now.Local()
148 todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())174 todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
149 yesterdayStart := todayStart.AddDate(0, 0, -1)175 yesterdayStart := todayStart.AddDate(0, 0, -1)
150 entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())176 entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
@@ -175,6 +201,8 @@ func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
175 if entry == nil {201 if entry == nil {
176 continue202 continue
177 }203 }
204+ // Each HistoryEntry represents at least one visit, even when VisitCount
205+ // is unset or non-positive in older persisted rows.
178 if entry.VisitCount <= 0 {206 if entry.VisitCount <= 0 {
179 visits++207 visits++
180 continue208 continue
@@ -195,23 +223,6 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
195 return int64(len(seen))223 return int64(len(seen))
196 }224 }
197 225
198-func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
199- domain = browserurl.CanonicalDomain(domain)
200- if domain == "" {
201- return entries
202- }
203- filtered := make([]*entity.HistoryEntry, 0, len(entries))
204- for _, entry := range entries {
205- if entry == nil {
206- continue
207- }
208- if browserurl.CanonicalDomain(entry.URL) == domain {
209- filtered = append(filtered, entry)
210- }
211- }
212- return filtered
213-}
214-
215 func displayHistoryDomain(raw string) string {226 func displayHistoryDomain(raw string) string {
216 return browserurl.DisplayDomain(raw)227 return browserurl.DisplayDomain(raw)
217 }228 }
A internal/ui/systemviews/history_view_test.go +23 -0
@@ -0,0 +1,23 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) {
11+ t.Parallel()
12+
13+ loc := time.FixedZone("test", 2*60*60)
14+ now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc)
15+
16+ _, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)
17+ _, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)
18+ _, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)
19+
20+ assert.Equal(t, "Today", todayLabel)
21+ assert.Equal(t, "Yesterday", yesterdayLabel)
22+ assert.Equal(t, "Mon Apr 20", olderLabel)
23+}
M internal/ui/systemviews/href.go +11 -2
@@ -7,7 +7,7 @@ import (
7 7
8 func sanitizeHref(raw string) string {8 func sanitizeHref(raw string) string {
9 trimmed := strings.TrimSpace(raw)9 trimmed := strings.TrimSpace(raw)
10- if trimmed == "" {10+ if trimmed == "" || strings.HasPrefix(trimmed, "//") {
11 return "#"11 return "#"
12 }12 }
13 13
@@ -24,7 +24,16 @@ func sanitizeHref(raw string) string {
24 }24 }
25 25
26 switch strings.ToLower(parsed.Scheme) {26 switch strings.ToLower(parsed.Scheme) {
27- case "http", "https", "dumb":27+ case "http", "https":
28+ if parsed.Host == "" {
29+ return "#"
30+ }
31+ parsed.Scheme = strings.ToLower(parsed.Scheme)
32+ return parsed.String()
33+ case "dumb":
34+ if parsed.Host == "" && parsed.Opaque == "" {
35+ return "#"
36+ }
28 return trimmed37 return trimmed
29 default:38 default:
30 return "#"39 return "#"
M internal/ui/systemviews/href_test.go +18 -0
@@ -34,6 +34,24 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
34 assert.Contains(t, html, `href="https://example.com"`)34 assert.Contains(t, html, `href="https://example.com"`)
35 }35 }
36 36
37+func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) {
38+ t.Parallel()
39+
40+ assert.Equal(t, "#", sanitizeHref("///evil.com"))
41+ assert.Equal(t, "#", sanitizeHref("//evil.com"))
42+ assert.Equal(t, "#", sanitizeHref("https:///evil.com"))
43+ assert.Equal(t, "#", sanitizeHref("http:evil.com"))
44+ assert.Equal(t, "https://example.com", sanitizeHref("https://example.com"))
45+}
46+
47+func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) {
48+ t.Parallel()
49+
50+ assert.Equal(t, "dumb://history", sanitizeHref("dumb://history"))
51+ assert.Equal(t, "dumb:history", sanitizeHref("dumb:history"))
52+ assert.Equal(t, "#", sanitizeHref("dumb:///history"))
53+}
54+
37 func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {55 func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {
38 t.Parallel()56 t.Parallel()
39 57
M internal/ui/systemviews/prefers_dark_js.go +5 -1
@@ -16,5 +16,9 @@ var currentPrefersDarkImpl = func() bool {
16 }16 }
17 17
18 media := matchMedia.Invoke("(prefers-color-scheme: dark)")18 media := matchMedia.Invoke("(prefers-color-scheme: dark)")
19- return media.Truthy() && media.Get("matches").Bool()19+ if !media.Truthy() {
20+ return false
21+ }
22+ matches := media.Get("matches")
23+ return matches.Truthy() && matches.Bool()
20 }24 }
M internal/ui/systemviews/render.go +2 -0
@@ -19,6 +19,8 @@ func renderComponent(ctx context.Context, component templ.Component) (string, er
19 }19 }
20 20
21 func mustRenderComponent(component templ.Component) string {21 func mustRenderComponent(component templ.Component) string {
22+ // Render helpers are pure HTML formatting paths; route/action contexts are
23+ // handled before rendering, so there is no caller context to preserve here.
22 html, err := renderComponent(context.Background(), component)24 html, err := renderComponent(context.Background(), component)
23 if err != nil {25 if err != nil {
24 return errorStateHTML(err.Error())26 return errorStateHTML(err.Error())
M internal/ui/systemviews/shell.go +17 -8
@@ -17,16 +17,28 @@ type kvPair struct {
17 // fragment. Route views populate this; the app frame wraps it.17 // fragment. Route views populate this; the app frame wraps it.
18 type renderedPage struct {18 type renderedPage struct {
19 route Route19 route Route
20+ title string
20 subtitle string21 subtitle string
21 body string22 body string
22 }23 }
23 24
24 // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,25 // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
25-// or <body>) that wraps route content in the shared shell.26+// or <body>) that wraps route content in the shared shell. page.body is
27+// inserted with templ.Raw, so callers must only pass trusted pre-rendered HTML.
26 func renderAppFrame(page renderedPage, theme shellTheme) string {28 func renderAppFrame(page renderedPage, theme shellTheme) string {
27 return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))29 return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))
28 }30 }
29 31
32+func pageDocumentTitle(page renderedPage) string {
33+ if title := strings.TrimSpace(page.title); title != "" {
34+ return title
35+ }
36+ if subtitle := strings.TrimSpace(page.subtitle); subtitle != "" {
37+ return subtitle
38+ }
39+ return routeSubtitle(page.route)
40+}
41+
30 func appRootClass(theme shellTheme) string {42 func appRootClass(theme shellTheme) string {
31 themeClass := theme.RootClass43 themeClass := theme.RootClass
32 if themeClass == "" {44 if themeClass == "" {
@@ -78,6 +90,7 @@ func listHTML(rows, emptyMessage string) string {
78 return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)90 return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)
79 }91 }
80 92
93+// listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.
81 func listRowHTML(inner string) string {94 func listRowHTML(inner string) string {
82 return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)95 return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)
83 }96 }
@@ -109,15 +122,11 @@ func kvRowHTML(label, value string) string {
109 return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))122 return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))
110 }123 }
111 124
112-// sectionHTML wraps content in a styled section with a heading.125+// sectionHTML wraps content in a styled section with a heading. inner is
126+// passed through as raw HTML, so callers must provide trusted markup.
113 func sectionHTML(class, title, inner string) string {127 func sectionHTML(class, title, inner string) string {
114- classes := "sv-section"
115- if strings.TrimSpace(class) != "" {
116- classes += " " + strings.TrimSpace(class)
117- }
118-
119 return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,128 return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,
120- html.EscapeString(classes),129+ sectionClass(class),
121 html.EscapeString(title),130 html.EscapeString(title),
122 inner,131 inner,
123 )132 )
M internal/ui/systemviews/shell_test.go +47 -1
@@ -26,8 +26,9 @@ func TestRenderAppFrame_ReturnsFragment(t *testing.T) {
26 // Root div carries theme class.26 // Root div carries theme class.
27 assert.Contains(t, out, `class="sv-app sv-dark"`)27 assert.Contains(t, out, `class="sv-app sv-dark"`)
28 28
29- // Route is on the root element.29+ // Route and document title are on the root element.
30 assert.Contains(t, out, `data-route="history"`)30 assert.Contains(t, out, `data-route="history"`)
31+ assert.Contains(t, out, `data-page-title="History"`)
31 32
32 // Shell wrapper present.33 // Shell wrapper present.
33 assert.Contains(t, out, `class="sv-shell"`)34 assert.Contains(t, out, `class="sv-shell"`)
@@ -52,6 +53,23 @@ func TestRenderAppFrame_DefaultThemeClass(t *testing.T) {
52 53
53 assert.Contains(t, out, `class="sv-app sv-dark"`)54 assert.Contains(t, out, `class="sv-app sv-dark"`)
54 assert.Contains(t, out, `data-route="favorites"`)55 assert.Contains(t, out, `data-route="favorites"`)
56+ assert.Contains(t, out, `data-page-title="Favorites"`)
57+}
58+
59+func TestRenderAppFrame_DocumentTitleUsesExplicitTitle(t *testing.T) {
60+ t.Parallel()
61+
62+ page := renderedPage{
63+ route: RouteHistory,
64+ title: "History — 12 entries",
65+ subtitle: "Recent visits",
66+ body: `<p>content</p>`,
67+ }
68+
69+ out := renderAppFrame(page, shellTheme{})
70+
71+ assert.Contains(t, out, `data-page-title="History — 12 entries"`)
72+ assert.Contains(t, out, "Recent visits")
55 }73 }
56 74
57 func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {75 func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {
@@ -112,6 +130,34 @@ func TestKVHTMLRendersPairs(t *testing.T) {
112 assert.Contains(t, out, "webkit")130 assert.Contains(t, out, "webkit")
113 }131 }
114 132
133+func TestKVHTMLEmptySlice(t *testing.T) {
134+ t.Parallel()
135+
136+ out := kvHTML(nil)
137+
138+ assert.Contains(t, out, `class="sv-empty"`)
139+ assert.Contains(t, out, "No configuration details")
140+}
141+
142+func TestKVHTMLAllEmptyRows(t *testing.T) {
143+ t.Parallel()
144+
145+ out := kvHTML([]kvPair{{}, {Label: " ", Value: ""}})
146+
147+ assert.Contains(t, out, `class="sv-empty"`)
148+ assert.Contains(t, out, "No configuration details")
149+}
150+
151+func TestErrorStateHTML(t *testing.T) {
152+ t.Parallel()
153+
154+ out := errorStateHTML("boom")
155+
156+ assert.Contains(t, out, `class="sv-error"`)
157+ assert.Contains(t, out, `role="alert"`)
158+ assert.Contains(t, out, "boom")
159+}
160+
115 func TestSectionHTML(t *testing.T) {161 func TestSectionHTML(t *testing.T) {
116 t.Parallel()162 t.Parallel()
117 163
M internal/ui/systemviews/theme.go +112 -12
@@ -3,6 +3,7 @@ package systemviews
3 import (3 import (
4 "fmt"4 "fmt"
5 "strings"5 "strings"
6+ "unicode"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 )9 )
@@ -17,6 +18,15 @@ const (
17 shellLightClass = "sv-light"18 shellLightClass = "sv-light"
18 )19 )
19 20
21+var cssNamedColors = func() map[string]struct{} {
22+ names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)
23+ colors := make(map[string]struct{}, len(names))
24+ for _, name := range names {
25+ colors[name] = struct{}{}
26+ }
27+ return colors
28+}()
29+
20 func currentPrefersDark() bool {30 func currentPrefersDark() bool {
21 return currentPrefersDarkImpl()31 return currentPrefersDarkImpl()
22 }32 }
@@ -54,23 +64,24 @@ func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
54 64
55 func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {65 func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {
56 parts := make([]string, 0, 11)66 parts := make([]string, 0, 11)
57- appendInlineVar := func(name, value string) {67+ appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
58- if strings.TrimSpace(value) == "" {68+ value, ok := sanitize(value)
69+ if !ok {
59 return70 return
60 }71 }
61 parts = append(parts, fmt.Sprintf("%s: %s;", name, value))72 parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
62 }73 }
63 74
64- appendInlineVar("--sv-background", palette.Background)75+ appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor)
65- appendInlineVar("--sv-surface", palette.Surface)76+ appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor)
66- appendInlineVar("--sv-surface-variant", palette.SurfaceVariant)77+ appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor)
67- appendInlineVar("--sv-text", palette.Text)78+ appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor)
68- appendInlineVar("--sv-muted", palette.Muted)79+ appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor)
69- appendInlineVar("--sv-accent", palette.Accent)80+ appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor)
70- appendInlineVar("--sv-border", palette.Border)81+ appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor)
71- appendInlineVar("--sv-font-sans", appearance.SansFont)82+ appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily)
72- appendInlineVar("--sv-font-serif", appearance.SerifFont)83+ appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily)
73- appendInlineVar("--sv-font-mono", appearance.MonospaceFont)84+ appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily)
74 if appearance.DefaultFontSize > 0 {85 if appearance.DefaultFontSize > 0 {
75 parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))86 parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
76 }87 }
@@ -78,6 +89,95 @@ func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPa
78 return strings.Join(parts, " ")89 return strings.Join(parts, " ")
79 }90 }
80 91
92+func sanitizeCSSColor(value string) (string, bool) {
93+ value = strings.TrimSpace(value)
94+ if value == "" || hasCSSBreakout(value) {
95+ return "", false
96+ }
97+ lower := strings.ToLower(value)
98+ if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) {
99+ return value, true
100+ }
101+ return "", false
102+}
103+
104+func sanitizeCSSFontFamily(value string) (string, bool) {
105+ value = strings.TrimSpace(value)
106+ if value == "" || hasCSSBreakout(value) {
107+ return "", false
108+ }
109+
110+ validated := value
111+ if quote := value[0]; quote == '\'' || quote == '"' {
112+ if len(value) < 2 || value[len(value)-1] != quote {
113+ return "", false
114+ }
115+ validated = value[1 : len(value)-1]
116+ if validated == "" || strings.ContainsAny(validated, "'\"") {
117+ return "", false
118+ }
119+ } else if strings.ContainsAny(value, "'\"") {
120+ return "", false
121+ }
122+
123+ for _, r := range validated {
124+ switch {
125+ case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r):
126+ case strings.ContainsRune(" ,._-", r):
127+ default:
128+ return "", false
129+ }
130+ }
131+ return value, true
132+}
133+
134+func hasCSSBreakout(value string) bool {
135+ return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/")
136+}
137+
138+func isHexColor(value string) bool {
139+ if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 {
140+ return false
141+ }
142+ if value[0] != '#' {
143+ return false
144+ }
145+ for _, r := range value[1:] {
146+ if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
147+ return false
148+ }
149+ }
150+ return true
151+}
152+
153+func isFunctionalColor(value string) bool {
154+ prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("}
155+ matched := false
156+ for _, prefix := range prefixes {
157+ if strings.HasPrefix(value, prefix) {
158+ matched = true
159+ break
160+ }
161+ }
162+ if !matched || !strings.HasSuffix(value, ")") {
163+ return false
164+ }
165+ for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
166+ if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) {
167+ return false
168+ }
169+ }
170+ return true
171+}
172+
173+func isNamedColor(value string) bool {
174+ if value == "transparent" || value == "currentcolor" {
175+ return true
176+ }
177+ _, ok := cssNamedColors[value]
178+ return ok
179+}
180+
81 func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {181 func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {
82 return appearance.ColorScheme == "" &&182 return appearance.ColorScheme == "" &&
83 appearance.SansFont == "" &&183 appearance.SansFont == "" &&
M internal/ui/systemviews/theme_test.go +60 -0
@@ -7,6 +7,66 @@ import (
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8 )8 )
9 9
10+func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) {
11+ t.Parallel()
12+
13+ vars := buildInlineVars(port.WebUIAppearanceConfig{
14+ SansFont: "Inter; color:red",
15+ SerifFont: "Georgia",
16+ MonospaceFont: "JetBrains Mono",
17+ LightPalette: port.ColorPalette{
18+ Background: "#ffffff; color:red",
19+ Surface: "rgb(1 2 3 / 50%)",
20+ Text: "transparent",
21+ Accent: "definitelynotacolor",
22+ Border: "#ABCDEF80",
23+ },
24+ }, port.ColorPalette{
25+ Background: "#ffffff; color:red",
26+ Surface: "rgb(1 2 3 / 50%)",
27+ Text: "transparent",
28+ Accent: "definitelynotacolor",
29+ Border: "#ABCDEF80",
30+ })
31+
32+ assert.NotContains(t, vars, "color:red")
33+ assert.NotContains(t, vars, "--sv-background")
34+ assert.NotContains(t, vars, "--sv-font-sans")
35+ assert.Contains(t, vars, "--sv-surface: rgb(1 2 3 / 50%);")
36+ assert.Contains(t, vars, "--sv-text: transparent;")
37+ assert.NotContains(t, vars, "definitelynotacolor")
38+ assert.Contains(t, vars, "--sv-border: #ABCDEF80;")
39+ assert.Contains(t, vars, "--sv-font-serif: Georgia;")
40+ assert.Contains(t, vars, "--sv-font-mono: JetBrains Mono;")
41+}
42+
43+func TestSanitizeCSSFontFamilyQuotes(t *testing.T) {
44+ t.Parallel()
45+
46+ tests := []struct {
47+ name string
48+ value string
49+ want bool
50+ }{
51+ {name: "unquoted spaces", value: "JetBrains Mono", want: true},
52+ {name: "single quoted token", value: "'JetBrains Mono'", want: true},
53+ {name: "double quoted token", value: "\"JetBrains Mono\"", want: true},
54+ {name: "unquoted single quote", value: "JetBrains' Mono", want: false},
55+ {name: "unquoted double quote", value: "JetBrains\" Mono", want: false},
56+ {name: "internal quote", value: "'JetBrains' Mono'", want: false},
57+ {name: "breakout", value: "'JetBrains Mono'; color:red", want: false},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ t.Parallel()
63+
64+ _, got := sanitizeCSSFontFamily(tt.value)
65+ assert.Equal(t, tt.want, got)
66+ })
67+ }
68+}
69+
10 func TestResolveShellTheme(t *testing.T) {70 func TestResolveShellTheme(t *testing.T) {
11 orig := currentPrefersDarkImpl71 orig := currentPrefersDarkImpl
12 t.Cleanup(func() {72 t.Cleanup(func() {

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