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:
5151 "arch=('x86_64')" \
5252 'url="https://github.com/bnema/dumber"' \
5353 "license=('MIT')" \
54- "makedepends=('go>=1.25' 'git')" \
54+ "makedepends=('go>=1.26' 'git' 'brotli')" \
5555 "depends=('gtk4' 'webkitgtk-6.0')" \
5656 'optdepends=(' \
5757 " 'gst-plugins-base: Base media codecs'" \
M .github/workflows/ci.yml +6 -6
@@ -47,7 +47,7 @@ jobs:
4747 - name: Install dependencies
4848 run: |
4949 pacman -Syu --noconfirm
50- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel
50+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
5151 - uses: actions/checkout@v6
5252 - name: Cache Go modules
5353 uses: actions/cache@v5
@@ -63,8 +63,8 @@ jobs:
6363 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
6464 restore-keys: |
6565 ${{ runner.os }}-go-build-
66- - name: Build systemviews assets
67- run: make build-systemviews
66+ - name: Build application and systemviews assets
67+ run: make build
6868 - name: Install golangci-lint
6969 run: |
7070 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest
@@ -83,7 +83,7 @@ jobs:
8383 - name: Install dependencies
8484 run: |
8585 pacman -Syu --noconfirm
86- pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
86+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli
8787 - uses: actions/checkout@v6
8888 - name: Cache Go modules
8989 uses: actions/cache@v5
@@ -99,8 +99,8 @@ jobs:
9999 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
100100 restore-keys: |
101101 ${{ runner.os }}-go-build-
102- - name: Build systemviews assets
103- run: make build-systemviews
102+ - name: Build application and systemviews assets
103+ run: make build
104104 - name: Run tests
105105 env:
106106 GDK_BACKEND: wayland
M .gitignore +1 -0
@@ -65,6 +65,7 @@ tmp/go-mod/
6565 tmp/
6666 assets/webui
6767 assets/systemviews/systemviews.wasm
68+assets/systemviews/systemviews.wasm.br
6869 assets/systemviews/wasm_exec.js
6970
7071 # Node/TypeScript related
M .mockery.yaml +1 -0
@@ -88,6 +88,7 @@ packages:
8888 UpdateChecker: {}
8989 UpdateDownloader: {}
9090 WebUIConfigSaver: {}
91+ SystemviewConfigReader: {}
9192 XDGPaths: {}
9293 github.com/bnema/dumber/internal/domain/repository:
9394 config:
M CONTRIBUTING.md +1 -1
@@ -35,7 +35,7 @@ git push origin my-fix
3535 3. Run `go fmt` on any Go code changes
3636
3737 **Systemviews assets:**
38-1. Run `make build-systemviews` to regenerate embedded WASM assets
38+1. If you modified systemviews files such as `cmd/systemviews/` or `.templ` components, run `make build-systemviews` to regenerate embedded WASM assets
3939
4040 Keep commits focused and use single-line commit messages.
4141
M Makefile +3 -2
@@ -53,9 +53,10 @@ generate-systemviews: ## Generate Go code from systemviews templ components
5353
5454 build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
5555 @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; }
5657 @mkdir -p assets/systemviews
5758 @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/systemviews
59+ GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
5960 brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br
6061 @echo "Systemviews build complete"
6162
@@ -145,7 +146,7 @@ clean: ## Clean build artifacts
145146 @echo "Cleaning build artifacts..."
146147 rm -rf $(DIST_DIR)
147148 rm -f $(BINARY_NAME)
148- rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm
149+ rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br
149150 rm -f coverage.out coverage.html
150151 go clean -cache
151152 go clean -testcache
M assets/systemviews/index.html +54 -17
@@ -8,27 +8,64 @@
88 <link rel="stylesheet" href="./systemviews.css">
99 <script src="./wasm_exec.js"></script>
1010 <script>
11- const go = new Go();
12- WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject)
13- .then((result) => go.run(result.instance))
14- .catch((err) => {
11+ function renderWasmError(prefixText, err) {
12+ const render = () => {
1513 const app = document.getElementById('app');
16- if (app) {
17- app.innerHTML = '';
18- const message = document.createElement('div');
19- message.className = 'sv-error';
20- const prefix = document.createElement('span');
21- prefix.textContent = 'Failed to load systemviews WebAssembly: ';
22- const details = document.createElement('span');
23- details.textContent = err && err.message ? err.message : String(err);
24- message.append(prefix, details);
25- app.append(message);
26- }
27- console.error('WASM load error:', err);
28- });
14+ if (!app) return;
15+ app.innerHTML = '';
16+ const message = document.createElement('div');
17+ message.className = 'sv-error';
18+ const prefix = document.createElement('span');
19+ prefix.textContent = prefixText;
20+ const details = document.createElement('span');
21+ details.textContent = err && err.message ? err.message : String(err || 'unsupported browser');
22+ message.append(prefix, details);
23+ app.append(message);
24+ };
25+ if (document.readyState === 'loading' && !document.getElementById('app')) {
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+ }
2963 </script>
3064 </head>
3165 <body>
3266 <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>
3370 </body>
3471 </html>
M assets/systemviews/systemviews.css +159 -31
@@ -1,9 +1,17 @@
11 :root {
2- color-scheme: dark light;
3- --sv-bg: #0f1115;
4- --sv-fg: #e8ebf0;
2+ color-scheme: dark;
3+ --sv-background: #0f1115;
4+ --sv-surface: #171a21;
5+ --sv-surface-variant: #1d2129;
6+ --sv-text: #e8ebf0;
57 --sv-muted: #9aa3b2;
68 --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;
715 }
816
917 body {
@@ -16,8 +24,28 @@ body {
1624
1725 .sv-app {
1826 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;
1933 }
2034
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+
2149 .sv-shell {
2250 display: grid;
2351 gap: 1rem;
@@ -52,7 +80,7 @@ body {
5280 .sv-list-row {
5381 padding: 0.75rem;
5482 border: 1px solid var(--sv-border, #2a313d);
55- border-radius: 0.65rem;
83+ border-radius: var(--sv-radius-card, 0.5rem);
5684 background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);
5785 }
5886
@@ -81,14 +109,14 @@ body {
81109 .sv-header {
82110 background: var(--sv-surface-variant, #1d2129);
83111 border: 1px solid var(--sv-border, #2a313d);
84- border-radius: 0.75rem;
112+ border-radius: var(--sv-radius-card, 0.5rem);
85113 padding: 1rem;
86114 }
87115
88116 .sv-section {
89117 background: var(--sv-surface, #171a21);
90118 border: 1px solid var(--sv-border, #2a313d);
91- border-radius: 0.75rem;
119+ border-radius: var(--sv-radius-card, 0.5rem);
92120 padding: 1rem;
93121 }
94122
@@ -101,7 +129,7 @@ body {
101129 margin-top: 1rem;
102130 padding: 1rem;
103131 border: 1px solid var(--sv-border, #2a313d);
104- border-radius: 0.75rem;
132+ border-radius: var(--sv-radius-card, 0.5rem);
105133 background: var(--sv-surface, #171a21);
106134 }
107135
@@ -156,7 +184,7 @@ body {
156184 display: inline-flex;
157185 align-items: center;
158186 padding: 0.2rem 0.45rem;
159- border-radius: 0.45rem;
187+ border-radius: var(--sv-radius-control, 0.35rem);
160188 border: 1px solid var(--sv-border, #2a313d);
161189 background: var(--sv-surface-variant, #1d2129);
162190 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -187,7 +215,7 @@ body {
187215 gap: 0.15rem;
188216 padding: 0.75rem;
189217 border: 1px solid var(--sv-border, #2a313d);
190- border-radius: 0.65rem;
218+ border-radius: var(--sv-radius-card, 0.5rem);
191219 background: var(--sv-surface-variant, #1d2129);
192220 }
193221
@@ -216,7 +244,7 @@ body {
216244 gap: 0.15rem;
217245 padding: 0.45rem 0.65rem;
218246 border: 1px solid var(--sv-border, #2a313d);
219- border-radius: 999px;
247+ border-radius: var(--sv-radius-chip, 0.45rem);
220248 background: var(--sv-surface-variant, #1d2129);
221249 }
222250
@@ -260,9 +288,9 @@ body {
260288 }
261289
262290 .sv-error {
263- border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
264- background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
265- border-radius: 0.75rem;
291+ border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d));
292+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
293+ border-radius: var(--sv-radius-card, 0.5rem);
266294 color: var(--sv-text, #e8ebf0);
267295 padding: 1rem;
268296 }
@@ -304,18 +332,26 @@ body {
304332 font-weight: 600;
305333 }
306334 .sv-alert {
307- border-radius: 0.75rem;
335+ border-radius: var(--sv-radius-card, 0.5rem);
308336 padding: 0.75rem 1rem;
309337 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);
310344 }
311345
312346 .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));
314349 }
315350
316351 .sv-alert-error {
317- background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
318- border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
352+ color: var(--sv-danger-text, #fecaca);
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));
319355 }
320356
321357 .sv-toolbar,
@@ -349,7 +385,7 @@ body {
349385
350386 .sv-search-label input {
351387 border: 1px solid var(--sv-border, #2a313d);
352- border-radius: 0.55rem;
388+ border-radius: var(--sv-radius-control, 0.35rem);
353389 padding: 0.55rem 0.65rem;
354390 background: var(--sv-surface-variant, #1d2129);
355391 color: var(--sv-text, #e8ebf0);
@@ -357,12 +393,13 @@ body {
357393 }
358394
359395 .sv-button {
396+ box-sizing: border-box;
360397 display: inline-flex;
361398 align-items: center;
362399 justify-content: center;
363400 min-height: 2rem;
364401 border: 1px solid var(--sv-accent, #8ab4ff);
365- border-radius: 0.55rem;
402+ border-radius: var(--sv-radius-control, 0.35rem);
366403 padding: 0.35rem 0.65rem;
367404 background: var(--sv-accent, #8ab4ff);
368405 color: var(--sv-background, #0f1115);
@@ -379,13 +416,18 @@ body {
379416 }
380417
381418 .sv-button-danger {
382- border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d));
383- background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129));
384- color: #fecaca;
419+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d));
420+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129));
421+ color: var(--sv-danger-text, #fecaca);
385422 }
386423
387424 .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;
389431 }
390432
391433 .sv-button:focus-visible,
@@ -414,7 +456,7 @@ body {
414456 gap: 0.35rem;
415457 align-items: center;
416458 border: 1px solid var(--sv-border, #2a313d);
417- border-radius: 999px;
459+ border-radius: var(--sv-radius-chip, 0.45rem);
418460 padding: 0.25rem 0.55rem;
419461 background: var(--sv-surface-variant, #1d2129);
420462 }
@@ -446,7 +488,7 @@ body {
446488 }
447489
448490 .sv-danger {
449- color: #fecaca;
491+ color: var(--sv-danger-text, #fecaca);
450492 }
451493
452494 .sv-icon-button:hover,
@@ -458,12 +500,12 @@ body {
458500 .sv-history-item.sv-focused {
459501 outline: 2px solid var(--sv-accent, #8ab4ff);
460502 outline-offset: 0.35rem;
461- border-radius: 0.5rem;
503+ border-radius: var(--sv-radius-control, 0.35rem);
462504 }
463505
464506 .sv-key-hints kbd {
465507 border: 1px solid var(--sv-border, #2a313d);
466- border-radius: 0.35rem;
508+ border-radius: 0.25rem;
467509 padding: 0.1rem 0.3rem;
468510 background: var(--sv-surface-variant, #1d2129);
469511 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -481,7 +523,7 @@ body {
481523 .sv-card-form {
482524 padding: 0.75rem;
483525 border: 1px solid var(--sv-border, #2a313d);
484- border-radius: 0.75rem;
526+ border-radius: var(--sv-radius-card, 0.5rem);
485527 background: var(--sv-surface-variant, #1d2129);
486528 }
487529
@@ -503,13 +545,37 @@ body {
503545 .sv-fieldset input,
504546 .sv-fieldset select {
505547 border: 1px solid var(--sv-border, #2a313d);
506- border-radius: 0.55rem;
548+ border-radius: var(--sv-radius-control, 0.35rem);
507549 padding: 0.5rem 0.6rem;
508550 background: var(--sv-surface-variant, #1d2129);
509551 color: var(--sv-text, #e8ebf0);
510552 font: inherit;
511553 }
512554
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+
513579 .sv-config-form,
514580 .sv-fieldset {
515581 display: flex;
@@ -523,7 +589,7 @@ body {
523589 margin: 0;
524590 padding: 0.75rem;
525591 border: 1px solid var(--sv-border, #2a313d);
526- border-radius: 0.65rem;
592+ border-radius: var(--sv-radius-card, 0.5rem);
527593 }
528594
529595 .sv-fieldset legend {
@@ -545,11 +611,73 @@ body {
545611 margin-top: 0.75rem;
546612 }
547613
614+.sv-history-row-actions .sv-button {
615+ min-width: 4.75rem;
616+}
617+
548618 .sv-button-active {
549619 border-color: var(--sv-accent, #8ab4ff);
550620 box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;
551621 }
552622
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+
553681 .sv-favorite-item {
554682 display: grid;
555683 gap: 0.75rem;
@@ -581,7 +709,7 @@ body {
581709 .sv-favorite-item.sv-focused {
582710 outline: 2px solid var(--sv-accent, #8ab4ff);
583711 outline-offset: 0.35rem;
584- border-radius: 0.5rem;
712+ border-radius: var(--sv-radius-control, 0.35rem);
585713 }
586714
587715 @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)"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-makedepends=('go>=1.26' 'git')
9+makedepends=('go>=1.26' 'git' 'brotli')
1010 depends=('gtk4' 'webkitgtk-6.0')
1111 optdepends=(
1212 'gst-plugins-base: Base media codecs'
M cmd/systemviews/bootstrap.go +4 -1
@@ -14,11 +14,12 @@ type bridgeServices interface {
1414 }
1515
1616 func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {
17+ route := systemviews.ParseRoute(locationURI)
1718 return systemviews.NewApp(systemviews.Dependencies{
1819 DOM: dom,
1920 History: bridge,
2021 Favorites: bridge,
21- Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)},
22+ Config: bridgeConfigProxy{bridge: bridge, route: route},
2223 LocationURI: locationURI,
2324 })
2425 }
@@ -41,6 +42,8 @@ func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error
4142 }
4243
4344 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.
4447 if p.route != systemviews.RouteConfig {
4548 return port.KeybindingsConfig{}, nil
4649 }
M cmd/systemviews/bootstrap_test.go +3 -2
@@ -96,7 +96,8 @@ func (f *fakeDOM) Mount(html string) error {
9696 return nil
9797 }
9898
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.
100101 type fakeBridgeService struct {
101102 calledHistory bool
102103 calledFavorites bool
@@ -166,7 +167,7 @@ func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { retu
166167
167168 func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }
168169
169-func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) {
170+func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) {
170171 return nil, nil
171172 }
172173
M cmd/systemviews/main.go +5 -2
@@ -13,8 +13,11 @@ func main() {
1313 bridge := systemviewsbridge.NewBrowserClient()
1414 href := ""
1515 location := js.Global().Get("location")
16- if location.Type() != js.TypeUndefined && location.Type() != js.TypeNull {
17- href = location.Get("href").String()
16+ if location.Truthy() {
17+ hrefValue := location.Get("href")
18+ if hrefValue.Truthy() && hrefValue.Type() == js.TypeString {
19+ href = hrefValue.String()
20+ }
1821 }
1922 app := newBridgeApp(systemviews.NewDOM(), href, bridge)
2023
M docs/reference/keybindings.md +5 -5
@@ -112,19 +112,19 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
112112 split-right = ["arrowright", "r"]
113113 close-pane = ["x", "q"]
114114
115-[workspace.shortcuts.actions.close_pane]
115+[workspace.shortcuts.actions.close-pane]
116116 keys = ["ctrl+w"]
117117
118-[workspace.shortcuts.actions.toggle_floating_pane]
118+[workspace.shortcuts.actions.toggle-floating-pane]
119119 keys = ["alt+f"]
120120
121-[workspace.shortcuts.actions.toggle_history_systemview]
121+[workspace.shortcuts.actions.toggle-history-systemview]
122122 keys = ["ctrl+h"]
123123
124-[workspace.shortcuts.actions.toggle_favorites_systemview]
124+[workspace.shortcuts.actions.toggle-favorites-systemview]
125125 keys = []
126126
127-[workspace.shortcuts.actions.toggle_config_systemview]
127+[workspace.shortcuts.actions.toggle-config-systemview]
128128 keys = []
129129
130130 [workspace.floating_pane]
M go.mod +1 -1
@@ -5,7 +5,7 @@ go 1.26.1
55 require (
66 github.com/a-h/templ v0.3.1001
77 github.com/bnema/purego v0.11.0-bnema.2
8- github.com/bnema/purego-cef v0.10.2
8+ github.com/bnema/purego-cef v0.11.0
99 github.com/bnema/purego-ffmpeg v0.3.1
1010 github.com/bnema/purego-pipewire v0.1.2
1111 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 {
6161 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
6262 Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
6363 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)
6565 DeleteFolder(ctx context.Context, id entity.FolderID) error
6666 UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error
6767 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
72407240 }
72417241
72427242 // 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) {
7244- ret := _mock.Called(ctx, name, parentID)
7243+func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
7244+ ret := _mock.Called(ctx, name, icon, parentID)
72457245
72467246 if len(ret) == 0 {
72477247 panic("no return value specified for CreateFolder")
@@ -7249,18 +7249,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin
72497249
72507250 var r0 *entity.Folder
72517251 var r1 error
7252- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok {
7253- return returnFunc(ctx, name, parentID)
7252+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok {
7253+ return returnFunc(ctx, name, icon, parentID)
72547254 }
7255- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok {
7256- r0 = returnFunc(ctx, name, parentID)
7255+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok {
7256+ r0 = returnFunc(ctx, name, icon, parentID)
72577257 } else {
72587258 if ret.Get(0) != nil {
72597259 r0 = ret.Get(0).(*entity.Folder)
72607260 }
72617261 }
7262- if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok {
7263- r1 = returnFunc(ctx, name, parentID)
7262+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok {
7263+ r1 = returnFunc(ctx, name, icon, parentID)
72647264 } else {
72657265 r1 = ret.Error(1)
72667266 }
@@ -7275,12 +7275,13 @@ type MockHomepageFavorites_CreateFolder_Call struct {
72757275 // CreateFolder is a helper method to define mock.On call
72767276 // - ctx context.Context
72777277 // - name string
7278+// - icon string
72787279 // - parentID *entity.FolderID
7279-func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {
7280- return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)}
7280+func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {
7281+ return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)}
72817282 }
72827283
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 {
72847285 _c.Call.Run(func(args mock.Arguments) {
72857286 var arg0 context.Context
72867287 if args[0] != nil {
@@ -7290,14 +7291,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont
72907291 if args[1] != nil {
72917292 arg1 = args[1].(string)
72927293 }
7293- var arg2 *entity.FolderID
7294+ var arg2 string
72947295 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)
72967301 }
72977302 run(
72987303 arg0,
72997304 arg1,
73007305 arg2,
7306+ arg3,
73017307 )
73027308 })
73037309 return _c
@@ -7308,7 +7314,7 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder,
73087314 return _c
73097315 }
73107316
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 {
73127318 _c.Call.Return(run)
73137319 return _c
73147320 }
@@ -12079,6 +12085,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context.
1207912085 return _c
1208012086 }
1208112087
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+
1208212235 // 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.
1208312236 // The first argument is typically a *testing.T value.
1208412237 func NewMockWebUIConfigSaver(t interface {
M internal/application/port/systemviews.go +1 -1
@@ -28,7 +28,7 @@ type SystemviewFavoritesService interface {
2828 ListTags(ctx context.Context) ([]*entity.Tag, error)
2929 SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
3030 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)
3232 UpdateFolder(ctx context.Context, id int64, name, icon string) error
3333 DeleteFolder(ctx context.Context, id int64) error
3434 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 {
1212 }
1313
1414 // SystemviewCustomPerformancePayload is the editable custom performance profile shape.
15-type SystemviewCustomPerformancePayload struct {
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-}
15+type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig
2216
2317 // SystemviewResolvedPerformancePayload is the resolved effective performance shape.
2418 type SystemviewResolvedPerformancePayload struct {
M internal/application/usecase/manage_favorites.go +96 -21
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "net/url"
67 "sort"
78 "strings"
89 "sync"
@@ -69,6 +70,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
6970 return existing, nil
7071 }
7172
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)
7278 fav := entity.NewFavorite(input.URL, input.Title)
7379 fav.FaviconURL = input.FaviconURL
7480 fav.FolderID = input.FolderID
@@ -95,8 +101,14 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
95101
96102 // AddFavorite creates a favorite from the UI-facing application port.
97103 func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) {
98- if strings.TrimSpace(input.URL) == "" {
99- return nil, fmt.Errorf("favorite URL is required")
104+ favoriteURL, err := normalizeFavoriteURL(input.URL)
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
100112 }
101113 tags := make([]entity.TagID, 0, len(input.Tags))
102114 for _, tagID := range input.Tags {
@@ -104,15 +116,66 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.Fa
104116 tags = append(tags, tagID)
105117 }
106118 }
107- return uc.Add(ctx, AddFavoriteInput{
108- URL: strings.TrimSpace(input.URL),
109- Title: strings.TrimSpace(input.Title),
119+ title := strings.TrimSpace(input.Title)
120+ if title == "" {
121+ title = favoriteURL
122+ }
123+ return uc.addNewFavorite(ctx, AddFavoriteInput{
124+ URL: favoriteURL,
125+ Title: title,
110126 FaviconURL: strings.TrimSpace(input.FaviconURL),
111127 FolderID: input.FolderID,
112128 Tags: tags,
113129 })
114130 }
115131
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+
116179 // UpdateFavorite updates editable favorite metadata from the UI-facing application port.
117180 func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) {
118181 if input.ID <= 0 {
@@ -130,7 +193,11 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port
130193 return nil, fmt.Errorf("favorite %d not found", input.ID)
131194 }
132195
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
134201 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
135202 fav.FolderID = input.FolderID
136203 fav.ShortcutKey = input.ShortcutKey
@@ -375,18 +442,20 @@ type ToggleResult struct {
375442
376443 // Toggle adds or removes a URL from favorites based on current state.
377444 // 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) {
379- log := logging.FromContext(ctx)
380- log.Debug().Str("url", url).Msg("toggling favorite")
381-
382- if url == "" {
383- return nil, fmt.Errorf("URL cannot be empty")
445+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) {
446+ favoriteURL, err := normalizeFavoriteURL(rawURL)
447+ if err != nil {
448+ return nil, err
384449 }
450+ title = strings.TrimSpace(title)
385451
386- // Check if already favorited
387- existing, err := uc.favoriteRepo.FindByURL(ctx, url)
452+ log := logging.FromContext(ctx)
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)
388457 if err != nil {
389- return nil, fmt.Errorf("failed to check existing favorite: %w", err)
458+ return nil, err
390459 }
391460
392461 if existing != nil {
@@ -394,26 +463,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
394463 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
395464 return nil, fmt.Errorf("failed to remove favorite: %w", err)
396465 }
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")
398467 uc.invalidateCache()
399468 return &ToggleResult{
400469 Added: false,
401- URL: url,
470+ URL: favoriteURL,
402471 Title: existing.Title,
403472 Message: "Favorite removed",
404473 }, nil
405474 }
406475
407476 // Add favorite
408- fav := entity.NewFavorite(url, title)
477+ fav := entity.NewFavorite(favoriteURL, title)
409478 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
410479 return nil, fmt.Errorf("failed to add favorite: %w", err)
411480 }
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")
413482 uc.invalidateCache()
414483 return &ToggleResult{
415484 Added: true,
416- URL: url,
485+ URL: favoriteURL,
417486 Title: title,
418487 Message: "Favorite added",
419488 }, nil
@@ -509,11 +578,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite
509578 }
510579
511580 // 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+ }
513587 log := logging.FromContext(ctx)
514588 log.Debug().Str("name", name).Msg("creating folder")
515589
516590 folder := entity.NewFolder(name)
591+ folder.Icon = icon
517592 folder.ParentID = parentID
518593
519594 if err := uc.folderRepo.Save(ctx, folder); err != nil {
M internal/application/usecase/manage_favorites_test.go +92 -1
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/bnema/dumber/internal/domain/entity"
910 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
142143 result, err := uc.Toggle(ctx, "", "Title")
143144 require.Error(t, err)
144145 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)
146170 }
147171
148172 func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
@@ -342,3 +366,70 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin
342366 require.Len(t, favoritesAfter, 1)
343367 assert.Nil(t, favoritesAfter[0].FolderID)
344368 }
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
22
33 import (
44 "context"
5- "fmt"
5+ "errors"
66
77 "github.com/bnema/dumber/internal/application/port"
88 )
@@ -14,13 +14,19 @@ type ReadSystemviewConfigUseCase struct {
1414
1515 // NewReadSystemviewConfigUseCase creates a new read systemview config use case.
1616 func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase {
17+ if reader == nil {
18+ panic("NewReadSystemviewConfigUseCase: reader is nil")
19+ }
1720 return &ReadSystemviewConfigUseCase{reader: reader}
1821 }
1922
23+// ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.
24+var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")
25+
2026 // Current returns the current systemview config payload.
2127 func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
2228 if uc == nil || uc.reader == nil {
23- return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil")
29+ return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
2430 }
2531 return uc.reader.Current(ctx)
2632 }
@@ -28,7 +34,7 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.System
2834 // Default returns the default systemview config payload.
2935 func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
3036 if uc == nil || uc.reader == nil {
31- return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil")
37+ return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
3238 }
3339 return uc.reader.Default(ctx)
3440 }
M internal/application/usecase/read_systemview_config_test.go +21 -22
@@ -5,48 +5,47 @@ import (
55 "testing"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/mock"
810 "github.com/stretchr/testify/require"
911 )
1012
1113 func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
12- reader := &stubSystemviewConfigReader{
13- current: port.SystemviewConfigPayload{EngineType: "webkit"},
14- }
14+ reader := portmocks.NewMockSystemviewConfigReader(t)
15+ want := port.SystemviewConfigPayload{EngineType: "webkit"}
16+ reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()
1517
1618 uc := NewReadSystemviewConfigUseCase(reader)
1719
1820 got, err := uc.Current(context.Background())
1921 require.NoError(t, err)
20- require.Equal(t, reader.current, got)
21- require.Equal(t, []string{"current"}, reader.calls)
22+ require.Equal(t, want, got)
2223 }
2324
2425 func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
25- reader := &stubSystemviewConfigReader{
26- defaultPayload: port.SystemviewConfigPayload{EngineType: "webkit"},
27- }
26+ reader := portmocks.NewMockSystemviewConfigReader(t)
27+ want := port.SystemviewConfigPayload{EngineType: "webkit"}
28+ reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()
2829
2930 uc := NewReadSystemviewConfigUseCase(reader)
3031
3132 got, err := uc.Default(context.Background())
3233 require.NoError(t, err)
33- require.Equal(t, reader.defaultPayload, got)
34- require.Equal(t, []string{"default"}, reader.calls)
34+ require.Equal(t, want, got)
3535 }
3636
37-// Handwritten fake to capture stateful config reads for assertions.
38-type stubSystemviewConfigReader struct {
39- current port.SystemviewConfigPayload
40- defaultPayload port.SystemviewConfigPayload
41- calls []string
37+func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) {
38+ require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() {
39+ NewReadSystemviewConfigUseCase(nil)
40+ })
4241 }
4342
44-func (r *stubSystemviewConfigReader) Current(context.Context) (port.SystemviewConfigPayload, error) {
45- r.calls = append(r.calls, "current")
46- return r.current, nil
47-}
43+func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) {
44+ var uc *ReadSystemviewConfigUseCase
45+
46+ _, err := uc.Current(context.Background())
47+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
4848
49-func (r *stubSystemviewConfigReader) Default(context.Context) (port.SystemviewConfigPayload, error) {
50- r.calls = append(r.calls, "default")
51- return r.defaultPayload, nil
49+ _, err = uc.Default(context.Background())
50+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
5251 }
M internal/application/usecase/save_webui_config.go +3 -2
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "math"
67 "strings"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -55,8 +56,8 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
5556 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
5657 errs = append(errs, "appearance.default_font_size must be between 1 and 72")
5758 }
58- if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
59- errs = append(errs, "default_ui_scale must be between 0.5 and 3.0")
59+ if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
60+ errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
6061 }
6162
6263 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
22
33 import (
44 "context"
5+ "math"
56 "testing"
67
78 "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
810 "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/stretchr/testify/mock"
912 "github.com/stretchr/testify/require"
1013 )
1114
@@ -36,13 +39,28 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
3639 },
3740 wantErr: "performance.profile",
3841 },
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+ },
3956 }
4057
4158 for _, tt := range tests {
4259 t.Run(tt.name, func(t *testing.T) {
4360 cfg := validWebUIConfig()
4461 tt.mutate(&cfg)
45- uc := usecase.NewSaveWebUIConfigUseCase(fakeWebUIConfigSaver{})
62+ saver := portmocks.NewMockWebUIConfigSaver(t)
63+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
4664
4765 err := uc.Execute(context.Background(), cfg)
4866
@@ -53,7 +71,11 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
5371 }
5472
5573 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()
5779 uc := usecase.NewSaveWebUIConfigUseCase(saver)
5880 cfg := validWebUIConfig()
5981 cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
@@ -61,9 +83,10 @@ func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
6183 err := uc.Execute(context.Background(), cfg)
6284
6385 require.NoError(t, err)
64- require.Contains(t, saver.saved.SearchShortcuts, "ddg")
65- require.Equal(t, "https://duckduckgo.com/?q=%s", saver.saved.SearchShortcuts["ddg"].URL)
66- require.Equal(t, "DuckDuckGo", saver.saved.SearchShortcuts["ddg"].Description)
86+ require.NotContains(t, saved.SearchShortcuts, " ddg ")
87+ require.Contains(t, saved.SearchShortcuts, "ddg")
88+ require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)
89+ require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
6790 }
6891
6992 func validWebUIConfig() port.WebUIConfig {
@@ -94,20 +117,3 @@ func validWebUIConfig() port.WebUIConfig {
94117 SearchShortcuts: map[string]port.SearchShortcut{},
95118 }
96119 }
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
8181 if limit <= 0 {
8282 limit = 50
8383 }
84- domain = domainurl.CanonicalDomain(domain)
85- if domain == "" {
86- return nil, fmt.Errorf("domain is required")
84+ domain, err := canonicalHistoryDomain(domain)
85+ if err != nil {
86+ return nil, err
8787 }
8888
8989 entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
@@ -93,6 +93,16 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st
9393 return entries, nil
9494 }
9595
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+
96106 // GetRecentSince retrieves history entries visited within the last N days.
97107 // If days is 0, returns all history entries.
98108 // If days is negative, defaults to 30 days.
@@ -173,7 +183,7 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
173183 return nil
174184 }
175185
176-// ClearRange deletes history entries for a named range.
186+// ClearRange deletes history entries inside a named recent range.
177187 func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
178188 cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
179189 if !ok {
@@ -183,7 +193,13 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string)
183193 return uc.ClearAll(ctx)
184194 }
185195
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
187203 }
188204
189205 // ClearAll deletes all history entries.
@@ -214,9 +230,9 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
214230
215231 // DeleteByDomain removes all history entries for a domain.
216232 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
217- domain = domainurl.CanonicalDomain(domain)
218- if domain == "" {
219- return fmt.Errorf("domain is required")
233+ domain, err := canonicalHistoryDomain(domain)
234+ if err != nil {
235+ return err
220236 }
221237
222238 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +57 -0
@@ -152,6 +152,39 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
152152 assert.Empty(t, result)
153153 }
154154
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+
155188 func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
156189 ctx := testContext()
157190
@@ -176,3 +209,27 @@ func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T)
176209 require.Error(t, err)
177210 assert.Contains(t, err.Error(), "unknown history delete range")
178211 }
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) {
1010 case "hour":
1111 return now.Add(-time.Hour), false, true
1212 case "day":
13- return now.AddDate(0, 0, -1), false, true
13+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), false, true
1414 case "week":
1515 return now.AddDate(0, 0, -7), false, true
1616 case "month":
M internal/domain/history/delete_range_test.go +1 -1
@@ -16,7 +16,7 @@ func TestDeleteRangeCutoff(t *testing.T) {
1616 wantOK bool
1717 }{
1818 {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},
2020 {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},
2121 {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},
2222 {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 {
4747 // DeleteOlderThan removes entries older than the given time.
4848 DeleteOlderThan(ctx context.Context, before time.Time) error
4949
50+ // DeleteSince removes entries visited at or after the given time.
51+ DeleteSince(ctx context.Context, since time.Time) error
52+
5053 // DeleteAll removes all history entries.
5154 DeleteAll(ctx context.Context) error
5255
M internal/domain/repository/mocks/mocks.go +57 -0
@@ -2129,6 +2129,63 @@ func (_c *MockHistoryRepository_DeleteOlderThan_Call) RunAndReturn(run func(ctx
21292129 return _c
21302130 }
21312131
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+
21322189 // FindByURL provides a mock function for the type MockHistoryRepository
21332190 func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
21342191 ret := _mock.Called(ctx, url)
M internal/domain/url/normalize.go +2 -4
@@ -192,10 +192,8 @@ func CanonicalDomain(raw string) string {
192192 if domain := ExtractDomain(raw); domain != "" {
193193 return domain
194194 }
195- for _, sep := range []string{"/", "?", "#"} {
196- if before, _, ok := strings.Cut(raw, sep); ok {
197- raw = before
198- }
195+ if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
196+ raw = raw[:idx]
199197 }
200198 return strings.TrimPrefix(raw, "www.")
201199 }
M internal/domain/url/normalize_test.go +28 -2
@@ -699,8 +699,12 @@ func TestCanonicalDomain(t *testing.T) {
699699 input string
700700 want string
701701 }{
702+ {name: "empty", input: "", want: ""},
703+ {name: "whitespace only", input: " ", want: ""},
702704 {name: "url", input: "https://www.example.com/path", want: "example.com"},
703705 {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"},
704708 {name: "domain", input: "www.example.com", want: "example.com"},
705709 {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
706710 }
@@ -715,7 +719,29 @@ func TestCanonicalDomain(t *testing.T) {
715719 }
716720
717721 func TestDisplayDomain(t *testing.T) {
718- if got := DisplayDomain("https://www.example.com:8443/path"); got != "example.com" {
719- t.Errorf("DisplayDomain() = %q, want %q", got, "example.com")
722+ tests := []struct {
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+ })
720746 }
721747 }
M internal/infrastructure/cef/scheme_handler.go +101 -7
@@ -1,24 +1,29 @@
11 package cef
22
33 import (
4+ "bytes"
45 "context"
56 "embed"
67 "encoding/base64"
78 "encoding/json"
89 "fmt"
910 "html"
11+ "io"
1012 "io/fs"
13+ "net"
1114 "net/http"
1215 "net/url"
13- "path/filepath"
16+ "path"
1417 "strings"
1518 "sync"
1619 "time"
1720 "unsafe"
1821
22+ "github.com/andybalholm/brotli"
1923 purecef "github.com/bnema/purego-cef/cef"
2024
2125 "github.com/bnema/dumber/internal/application/port"
26+ "github.com/bnema/dumber/internal/infrastructure/netguard"
2227 "github.com/bnema/dumber/internal/infrastructure/webutil"
2328 "github.com/bnema/dumber/internal/logging"
2429 "github.com/rs/zerolog"
@@ -32,6 +37,8 @@ const (
3237 errorPath = "error"
3338 indexHTML = "index.html"
3439 maxSchemeTruncatedURLLength = 240
40+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
41+ systemviewsAssetDir = "systemviews"
3542 )
3643
3744 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
@@ -100,7 +107,7 @@ func newDumbSchemeHandler(
100107 ctx: ctx,
101108 messageRouter: router,
102109 transcoder: transcoder,
103- assetDir: "systemviews",
110+ assetDir: systemviewsAssetDir,
104111 logger: log.With().Str("component", "scheme-handler").Logger(),
105112 currentConfigPayload: currentConfigPayload,
106113 defaultConfigPayload: defaultConfigPayload,
@@ -190,8 +197,14 @@ func isAPIPostMethod(method string) bool {
190197 func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {
191198 switch path {
192199 case "/api/config":
200+ if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
201+ return denied, true
202+ }
193203 return h.handleConfigAPI(h.currentConfigPayload), true
194204 case "/api/config/default":
205+ if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
206+ return denied, true
207+ }
195208 return h.handleConfigAPI(h.defaultConfigPayload), true
196209 case "/api/transcode":
197210 return h.handleTranscodeAPI(request), true
@@ -224,6 +237,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
224237 }
225238
226239 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
240+ if denied := h.rejectForbiddenAPIOrigin(request); denied != nil {
241+ return denied
242+ }
227243 body := readBodyFromHeader(request)
228244 if body == nil {
229245 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
@@ -235,6 +251,14 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re
235251 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
236252 }
237253
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+
238262 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
239263 if h.transcoder == nil {
240264 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
@@ -255,6 +279,9 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
255279 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
256280 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
257281 }
282+ if err := validateTranscodeSourceURL(h.ctx, sourceParsed); err != nil {
283+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": err.Error()})
284+ }
258285
259286 headers := make(map[string]string)
260287 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
@@ -287,6 +314,39 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
287314 })
288315 }
289316
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+
290350 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
291351 if build == nil {
292352 return nil, fmt.Errorf("config payload builder not configured")
@@ -499,7 +559,11 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
499559 assetDir = h.assetDir
500560 }
501561
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+
503567 data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
504568 if err != nil {
505569 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 {
516580 return newStaticResourceHandler(http.StatusOK, contentType, data, headers)
517581 }
518582
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+
519606 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
520607 if strings.HasSuffix(relPath, ".wasm") {
521- if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
522- return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil
608+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
609+ 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
523617 }
524618 }
525619 data, err := fs.ReadFile(assets, fullPath)
@@ -591,7 +685,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
591685 }
592686
593687 if !strings.Contains(path, "/") && strings.Contains(path, ".") {
594- return "systemviews", path, true
688+ return systemviewsAssetDir, path, true
595689 }
596690
597691 return "", "", false
@@ -600,7 +694,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
600694 func assetDirForPageHost(host string) string {
601695 switch host {
602696 case historyPath, favoritesPath, configPath, errorPath:
603- return "systemviews"
697+ return systemviewsAssetDir
604698 default:
605699 return ""
606700 }
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +49 -0
@@ -3,6 +3,8 @@ package cef
33 import (
44 "net/url"
55 "testing"
6+
7+ "github.com/bnema/dumber/assets"
68 )
79
810 func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
@@ -40,6 +42,21 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
4042 }
4143 }
4244
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+
4360 func mustParseURL(t *testing.T, raw string) *url.URL {
4461 t.Helper()
4562 u, err := url.Parse(raw)
@@ -48,3 +65,35 @@ func mustParseURL(t *testing.T, raw string) *url.URL {
4865 }
4966 return u
5067 }
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 (
44 "context"
55 "encoding/base64"
66 "errors"
7+ "net"
78 "net/http"
9+ "net/url"
810 "testing"
911
1012 purecef "github.com/bnema/purego-cef/cef"
@@ -60,6 +62,57 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
6062 require.Contains(t, err.Error(), "default config payload builder")
6163 }
6264
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+
63116 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
64117 request := cefmocks.NewMockRequest(t)
65118 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
M internal/infrastructure/config/defaults.go +11 -11
@@ -238,17 +238,17 @@ func DefaultConfig() *Config {
238238 },
239239 Shortcuts: GlobalShortcutsConfig{
240240 Actions: map[string]ActionBinding{
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"},
243- "toggle_favorites_systemview": {Keys: []string{}, Desc: "Toggle Favorites 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"},
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"},
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"},
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"},
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"},
243+ "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites 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"},
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"},
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"},
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"},
252252 },
253253 },
254254 FloatingPane: FloatingPaneConfig{
M internal/infrastructure/config/defaults_test.go +3 -3
@@ -28,9 +28,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
2828 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
2929
3030 // System view shortcuts are first-class global actions.
31- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_history_systemview", []string{"ctrl+h"})
32- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_favorites_systemview", []string{})
33- requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_config_systemview", []string{})
31+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
32+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
33+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
3434
3535 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
3636 // 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) {
1515 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
1616 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
1717
18- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
1919 require.True(t, ok)
2020 assert.Equal(t, []string{"alt+f"}, binding.Keys)
2121 }
@@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
3131 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
3232 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
3333
34- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
3535 require.True(t, ok)
3636 assert.Equal(t, []string{"alt+f"}, binding.Keys)
3737 }
M internal/infrastructure/config/migrate_test.go +4 -4
@@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
695695 for actionName, value := range defaultActions {
696696 userActions[actionName] = value
697697 }
698- delete(userActions, "toggle_floating_pane")
698+ delete(userActions, "toggle-floating-pane")
699699
700700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701701 "workspace.shortcuts.actions": userActions,
702702 })
703703
704- assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)
704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)
705705 }
706706
707707 func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
@@ -750,7 +750,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
750750 actions, ok := actionsAny.(map[string]any)
751751 require.True(t, ok)
752752
753- toggleAny, hasToggle := actions["toggle_floating_pane"]
753+ toggleAny, hasToggle := actions["toggle-floating-pane"]
754754 assert.True(t, hasToggle)
755755 assert.NotNil(t, toggleAny)
756756
@@ -764,7 +764,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
764764 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765765 m := NewMigrator()
766766
767- value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")
767+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
768768
769769 action, ok := value.(ActionBinding)
770770 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
4545 }
4646
4747 func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig {
48+ defaults := DefaultConfig().Appearance
4849 return port.WebUIAppearanceConfig{
4950 SansFont: appearance.SansFont,
5051 SerifFont: appearance.SerifFont,
5152 MonospaceFont: appearance.MonospaceFont,
5253 DefaultFontSize: appearance.DefaultFontSize,
5354 ColorScheme: appearance.ColorScheme,
54- LightPalette: port.ColorPalette{
55- Background: appearance.LightPalette.Background,
56- Surface: appearance.LightPalette.Surface,
57- SurfaceVariant: appearance.LightPalette.SurfaceVariant,
58- Text: appearance.LightPalette.Text,
59- Muted: appearance.LightPalette.Muted,
60- Accent: appearance.LightPalette.Accent,
61- Border: appearance.LightPalette.Border,
62- },
63- DarkPalette: port.ColorPalette{
64- Background: appearance.DarkPalette.Background,
65- Surface: appearance.DarkPalette.Surface,
66- SurfaceVariant: appearance.DarkPalette.SurfaceVariant,
67- Text: appearance.DarkPalette.Text,
68- Muted: appearance.DarkPalette.Muted,
69- Accent: appearance.DarkPalette.Accent,
70- Border: appearance.DarkPalette.Border,
71- },
55+ LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),
56+ DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette),
57+ }
58+}
59+
60+func buildSystemviewPalettePayload(palette, fallback ColorPalette) port.ColorPalette {
61+ return port.ColorPalette{
62+ Background: nonEmptyColor(palette.Background, fallback.Background),
63+ Surface: nonEmptyColor(palette.Surface, fallback.Surface),
64+ SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),
65+ Text: nonEmptyColor(palette.Text, fallback.Text),
66+ Muted: nonEmptyColor(palette.Muted, fallback.Muted),
67+ Accent: nonEmptyColor(palette.Accent, fallback.Accent),
68+ Border: nonEmptyColor(palette.Border, fallback.Border),
69+ }
70+}
71+
72+func nonEmptyColor(value, fallback string) string {
73+ if value != "" {
74+ return value
7275 }
76+ return fallback
7377 }
7478
7579 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
1313 require.NotNil(t, payload.SearchShortcuts)
1414 require.Empty(t, payload.SearchShortcuts)
1515 }
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)
2424 }
2525
2626 // 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) {
2828 if h == nil {
2929 return nil, fmt.Errorf("config handler is nil")
3030 }
@@ -39,7 +39,11 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
3939 return nil, fmt.Errorf("invalid config format: %w", err)
4040 }
4141
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")
4347
4448 if err := h.saveConfig(ctx, payloadCfg); err != nil {
4549 log.Error().Err(err).Msg("failed to save config")
M internal/infrastructure/handlers/homepage/favorites.go +33 -25
@@ -3,12 +3,32 @@ package homepage
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
7+ "strings"
68
79 "github.com/bnema/dumber/internal/application/port"
810 "github.com/bnema/dumber/internal/domain/entity"
911 "github.com/bnema/dumber/internal/logging"
1012 )
1113
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+
1232 // FavoritesHandlers handles favorite-related messages from the homepage.
1333 type FavoritesHandlers struct {
1434 favoritesUC port.HomepageFavorites
@@ -63,6 +83,10 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
6383 Str("url", req.URL).
6484 Msg("handling favorite_create")
6585
86+ if strings.TrimSpace(req.URL) == "" {
87+ return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
88+ }
89+
6690 input := port.FavoriteCreateInput{
6791 URL: req.URL,
6892 Title: req.Title,
@@ -103,6 +127,10 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
103127 Int64("favorite_id", req.ID).
104128 Msg("handling favorite_update")
105129
130+ if req.ID <= 0 {
131+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
132+ }
133+
106134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{
107135 ID: entity.FavoriteID(req.ID),
108136 Title: req.Title,
@@ -138,6 +166,10 @@ func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler {
138166 Int64("favorite_id", req.ID).
139167 Msg("handling favorite_delete")
140168
169+ if req.ID <= 0 {
170+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
171+ }
172+
141173 if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil {
142174 return NewErrorResponse(req.RequestID, err), nil
143175 }
@@ -214,24 +246,6 @@ type setFolderRequest struct {
214246 }
215247
216248 // 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-
235249 func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
236250 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
237251 log := logging.FromContext(ctx)
@@ -246,13 +260,7 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
246260 Int64("favorite_id", req.FavoriteID).
247261 Msg("handling favorite_set_folder")
248262
249- var folderID *entity.FolderID
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 {
263+ if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderIDFromInt64(req.FolderID)); err != nil {
256264 return NewErrorResponse(req.RequestID, err), nil
257265 }
258266
M internal/infrastructure/handlers/homepage/folders.go +6 -9
@@ -44,6 +44,7 @@ type createFolderRequest struct {
4444 RequestID string `json:"requestId"`
4545 Name string `json:"name"`
4646 Icon *string `json:"icon"`
47+ ParentID *int64 `json:"parent_id"`
4748 }
4849
4950 // HandleCreate handles folder_create messages.
@@ -61,19 +62,15 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
6162 Str("name", req.Name).
6263 Msg("handling folder_create")
6364
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))
6570 if err != nil {
6671 return NewErrorResponse(req.RequestID, err), nil
6772 }
6873
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-
7774 return NewSuccessResponse(req.RequestID, folder), nil
7875 })
7976 }
M internal/infrastructure/handlers/homepage/history.go +20 -0
@@ -3,6 +3,8 @@ package homepage
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
7+ "strings"
68
79 "github.com/bnema/dumber/internal/application/port"
810 "github.com/bnema/dumber/internal/domain/entity"
@@ -68,6 +70,11 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
6870 return NewErrorResponse("", err), nil
6971 }
7072
73+ req.Domain = strings.TrimSpace(req.Domain)
74+ if req.Domain == "" {
75+ return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil
76+ }
77+
7178 log.Debug().
7279 Str("request_id", req.RequestID).
7380 Str("domain", req.Domain).
@@ -169,11 +176,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
169176 return NewErrorResponse("", err), nil
170177 }
171178
179+ req.Range = strings.TrimSpace(req.Range)
172180 log.Debug().
173181 Str("request_id", req.RequestID).
174182 Str("range", req.Range).
175183 Msg("handling history_delete_range")
176184
185+ if !isAllowedHistoryDeleteRange(req.Range) {
186+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid range: %s", req.Range)), nil
187+ }
177188 if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
178189 return NewErrorResponse(req.RequestID, err), nil
179190 }
@@ -182,6 +193,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
182193 })
183194 }
184195
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+
185205 // HandleClearAll handles history_clear_all messages.
186206 func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
187207 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,
6161 }
6262 }()
6363
64+ shortcutKey := sql.NullInt64{}
65+ if fav.ShortcutKey != nil {
66+ shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true}
67+ }
68+
6469 queries := r.queries.WithTx(tx)
6570 if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
6671 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
@@ -73,7 +78,7 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite,
7378 return err
7479 }
7580 if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
76- ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil},
81+ ShortcutKey: shortcutKey,
7782 ID: int64(fav.ID),
7883 }); err != nil {
7984 return err
@@ -81,13 +86,6 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite,
8186 return tx.Commit()
8287 }
8388
84-func int64Value(value *int) int64 {
85- if value == nil {
86- return 0
87- }
88- return int64(*value)
89-}
90-
9189 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
9290 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
9391 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
6161 log := logging.FromContext(ctx)
6262 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
6363
64+ domain := domainurl.CanonicalDomain(entry.URL)
6465 err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
6566 Url: entry.URL,
6667 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
6768 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
69+ Domain: sql.NullString{String: domain, Valid: domain != ""},
6870 })
6971 if err != nil {
7072 return err
@@ -518,11 +520,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H
518520 return nil, err
519521 }
520522
521- entries := make([]*entity.HistoryEntry, len(rows))
522- for i := range rows {
523- entries[i] = historyFromRow(rows[i])
524- }
525- return entries, nil
523+ return historyEntriesFromRows(rows), nil
526524 }
527525
528526 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
536534 return nil, err
537535 }
538536
539- entries := make([]*entity.HistoryEntry, len(rows))
540- for i := range rows {
541- entries[i] = historyFromRow(rows[i])
542- }
543- return entries, nil
537+ return historyEntriesFromRows(rows), nil
544538 }
545539
546540 func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -549,11 +543,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor
549543 return nil, err
550544 }
551545
552- entries := make([]*entity.HistoryEntry, len(rows))
553- for i := range rows {
554- entries[i] = historyFromRow(rows[i])
555- }
556- return entries, nil
546+ return historyEntriesFromRows(rows), nil
557547 }
558548
559549 func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -562,11 +552,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
562552 return nil, err
563553 }
564554
565- entries := make([]*entity.HistoryEntry, len(rows))
566- for i := range rows {
567- entries[i] = historyFromRow(rows[i])
568- }
569- return entries, nil
555+ return historyEntriesFromRows(rows), nil
570556 }
571557
572558 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
599585 return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
600586 }
601587
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+
602592 func (r *historyRepo) DeleteAll(ctx context.Context) error {
603593 return r.queries.DeleteAllHistory(ctx)
604594 }
605595
606596 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
597+ rawDomain := domain
607598 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})
609604 }
610605
611606 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)
832832 require.NoError(t, err)
833833 assert.Empty(t, results)
834834 }
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
230230 return r.repo.DeleteOlderThan(ctx, before)
231231 }
232232
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+
233240 func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
234241 if err := r.init(ctx); err != nil {
235242 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 ?;
33
44 -- name: GetRecentHistoryByDomain :many
55 SELECT * FROM history
6-WHERE url LIKE '%://' || @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
6+WHERE domain = @domain
147 ORDER BY last_visited DESC
158 LIMIT @limit OFFSET @offset;
169
@@ -18,14 +11,15 @@ LIMIT @limit OFFSET @offset;
1811 SELECT * FROM history WHERE url = ? LIMIT 1;
1912
2013 -- name: UpsertHistory :exec
21-INSERT INTO history (url, title, favicon_url)
22-VALUES (?, ?, ?)
14+INSERT INTO history (url, title, favicon_url, domain)
15+VALUES (?, ?, ?, ?)
2316 ON CONFLICT(url)
2417 DO UPDATE SET
2518 visit_count = visit_count + 1,
2619 last_visited = CURRENT_TIMESTAMP,
2720 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;
2923
3024 -- name: IncrementVisitCount :exec
3125 UPDATE history
@@ -49,6 +43,9 @@ DELETE FROM history WHERE id = ?;
4943 -- name: DeleteHistoryOlderThan :exec
5044 DELETE FROM history WHERE last_visited < ?;
5145
46+-- name: DeleteHistorySince :exec
47+DELETE FROM history WHERE last_visited >= ?;
48+
5249 -- name: DeleteAllHistory :exec
5350 DELETE FROM history;
5451
@@ -56,7 +53,7 @@ DELETE FROM history;
5653 SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;
5754
5855 -- 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 ?;
6057
6158 -- name: GetHourlyDistribution :many
6259 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_
6562 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;
6663
6764 -- name: DeleteHistoryByDomain :exec
68-DELETE FROM history
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;
65+DELETE FROM history WHERE domain = @domain;
7766
7867 -- name: SearchHistoryFTSUrl :many
7968 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
9988 LIMIT @limit;
10089
10190 -- name: SearchHistoryFTSTitle :many
102-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
91+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
10392 FROM history_fts fts
10493 JOIN history h ON fts.rowid = h.id
10594 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 {
3535 }
3636
3737 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
38-DELETE FROM history
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
38+DELETE FROM history WHERE domain = ?1
4739 `
4840
4941 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
6961 return err
7062 }
7163
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+
7273 const GetAllMostVisited = `-- name: GetAllMostVisited :many
73-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
74+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
7475 ORDER BY visit_count DESC, last_visited DESC
7576 `
7677
@@ -91,6 +92,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
9192 &i.VisitCount,
9293 &i.LastVisited,
9394 &i.CreatedAt,
95+ &i.Domain,
9496 ); err != nil {
9597 return nil, err
9698 }
@@ -106,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
106108 }
107109
108110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
109-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
111+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
110112 ORDER BY last_visited DESC
111113 `
112114
@@ -127,6 +129,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
127129 &i.VisitCount,
128130 &i.LastVisited,
129131 &i.CreatedAt,
132+ &i.Domain,
130133 ); err != nil {
131134 return nil, err
132135 }
@@ -175,7 +178,7 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G
175178 }
176179
177180 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 ?
179182 `
180183
181184 type GetDomainStatsRow struct {
@@ -214,7 +217,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS
214217 }
215218
216219 const GetHistoryByURL = `-- name: GetHistoryByURL :one
217-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1
220+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history WHERE url = ? LIMIT 1
218221 `
219222
220223 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
228231 &i.VisitCount,
229232 &i.LastVisited,
230233 &i.CreatedAt,
234+ &i.Domain,
231235 )
232236 return i, err
233237 }
@@ -282,7 +286,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
282286 }
283287
284288 const GetMostVisited = `-- name: GetMostVisited :many
285-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
289+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
286290 WHERE last_visited >= datetime('now', ?)
287291 ORDER BY visit_count DESC, last_visited DESC
288292 `
@@ -304,6 +308,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
304308 &i.VisitCount,
305309 &i.LastVisited,
306310 &i.CreatedAt,
311+ &i.Domain,
307312 ); err != nil {
308313 return nil, err
309314 }
@@ -319,7 +324,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
319324 }
320325
321326 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 ?
323328 `
324329
325330 type GetRecentHistoryParams struct {
@@ -344,6 +349,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
344349 &i.VisitCount,
345350 &i.LastVisited,
346351 &i.CreatedAt,
352+ &i.Domain,
347353 ); err != nil {
348354 return nil, err
349355 }
@@ -359,15 +365,8 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
359365 }
360366
361367 const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
362-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
363-WHERE url LIKE '%://' || ?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
368+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
369+WHERE domain = ?1
371370 ORDER BY last_visited DESC
372371 LIMIT ?3 OFFSET ?2
373372 `
@@ -395,6 +394,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis
395394 &i.VisitCount,
396395 &i.LastVisited,
397396 &i.CreatedAt,
397+ &i.Domain,
398398 ); err != nil {
399399 return nil, err
400400 }
@@ -410,7 +410,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis
410410 }
411411
412412 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
413-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
413+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
414414 WHERE last_visited >= datetime('now', ?)
415415 ORDER BY last_visited DESC
416416 `
@@ -432,6 +432,7 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
432432 &i.VisitCount,
433433 &i.LastVisited,
434434 &i.CreatedAt,
435+ &i.Domain,
435436 ); err != nil {
436437 return nil, err
437438 }
@@ -474,7 +475,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV
474475 }
475476
476477 const SearchHistory = `-- name: SearchHistory :many
477-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
478+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
478479 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
479480 ORDER BY visit_count DESC, last_visited DESC
480481 LIMIT ?
@@ -503,6 +504,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
503504 &i.VisitCount,
504505 &i.LastVisited,
505506 &i.CreatedAt,
507+ &i.Domain,
506508 ); err != nil {
507509 return nil, err
508510 }
@@ -518,7 +520,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
518520 }
519521
520522 const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
521-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
523+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
522524 FROM history_fts fts
523525 JOIN history h ON fts.rowid = h.id
524526 WHERE fts.title MATCH ?1
@@ -548,6 +550,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
548550 &i.VisitCount,
549551 &i.LastVisited,
550552 &i.CreatedAt,
553+ &i.Domain,
551554 ); err != nil {
552555 return nil, err
553556 }
@@ -576,15 +579,25 @@ type SearchHistoryFTSUrlParams struct {
576579 Limit int64 `json:"limit"`
577580 }
578581
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) {
580593 rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
581594 if err != nil {
582595 return nil, err
583596 }
584597 defer rows.Close()
585- items := []History{}
598+ items := []SearchHistoryFTSUrlRow{}
586599 for rows.Next() {
587- var i History
600+ var i SearchHistoryFTSUrlRow
588601 if err := rows.Scan(
589602 &i.ID,
590603 &i.Url,
@@ -673,23 +686,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se
673686 }
674687
675688 const UpsertHistory = `-- name: UpsertHistory :exec
676-INSERT INTO history (url, title, favicon_url)
677-VALUES (?, ?, ?)
689+INSERT INTO history (url, title, favicon_url, domain)
690+VALUES (?, ?, ?, ?)
678691 ON CONFLICT(url)
679692 DO UPDATE SET
680693 visit_count = visit_count + 1,
681694 last_visited = CURRENT_TIMESTAMP,
682695 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
684698 `
685699
686700 type UpsertHistoryParams struct {
687701 Url string `json:"url"`
688702 Title sql.NullString `json:"title"`
689703 FaviconUrl sql.NullString `json:"favicon_url"`
704+ Domain sql.NullString `json:"domain"`
690705 }
691706
692707 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+ )
694714 return err
695715 }
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
@@ -56,6 +56,7 @@ type History struct {
5656 VisitCount sql.NullInt64 `json:"visit_count"`
5757 LastVisited sql.NullTime `json:"last_visited"`
5858 CreatedAt sql.NullTime `json:"created_at"`
59+ Domain sql.NullString `json:"domain"`
5960 }
6061
6162 type HistoryFt struct {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
@@ -24,6 +24,7 @@ type Querier interface {
2424 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
2525 DeleteHistoryByID(ctx context.Context, id int64) error
2626 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
27+ DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error
2728 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
2829 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
2930 DeletePermission(ctx context.Context, arg DeletePermissionParams) error
@@ -78,7 +79,7 @@ type Querier interface {
7879 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
7980 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
8081 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)
8283 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
8384 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
8485 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) {
5151 })
5252 }
5353
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.
5457 func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {
5558 trimmed := bytes.TrimSpace(body)
5659 if len(trimmed) == 0 {
@@ -67,21 +70,19 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error)
6770 _, hasCallback := topLevel["_callback"]
6871 _, hasData := topLevel["data"]
6972
73+ // Native callbacks can already return the final bridgeResponse shape.
7074 if (hasSuccess || hasRequestID) && !hasCallback {
7175 var resp bridgeResponse
7276 if err := json.Unmarshal(trimmed, &resp); err != nil {
7377 return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)
7478 }
7579 if resp.RequestID == "" {
80+ // Some direct shims omit requestId; preserve correlation for callers.
7681 resp.RequestID = requestID
7782 }
7883 return json.Marshal(resp)
7984 }
8085
81- if !hasData {
82- return nil, fmt.Errorf("wrapped bridge response missing data")
83- }
84-
8586 var wrapped struct {
8687 Data json.RawMessage `json:"data,omitempty"`
8788 Error string `json:"error,omitempty"`
@@ -90,20 +91,37 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error)
9091 return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)
9192 }
9293 if wrapped.Error != "" {
94+ // Fetch-style wrappers report transport errors outside the data payload.
9395 return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})
9496 }
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+ }
95101 if len(wrapped.Data) == 0 {
96102 return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
97103 }
98104
99- var nested bridgeResponse
100- if err := json.Unmarshal(wrapped.Data, &nested); err == nil {
101- hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != ""
105+ var nestedFields map[string]json.RawMessage
106+ if err := json.Unmarshal(wrapped.Data, &nestedFields); err == nil {
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)
102116 if hasNestedEnvelope {
103- if nested.RequestID == "" {
104- nested.RequestID = requestID
117+ var nested bridgeResponse
118+ 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)
105124 }
106- return json.Marshal(nested)
107125 }
108126 }
109127
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +213 -88
@@ -51,7 +51,8 @@ func (*browserTransport) Available() bool {
5151 if !window.Truthy() {
5252 return false
5353 }
54- return window.Get("fetch").Truthy() || window.Get("dumber").Truthy()
54+ bridge := window.Get("dumber")
55+ return bridge.Truthy() && bridge.Get("postMessage").Truthy()
5556 }
5657
5758 func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
@@ -83,6 +84,31 @@ func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error)
8384
8485 type webkitTransport struct{}
8586
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+
86112 func (*webkitTransport) Available() bool {
87113 window := js.Global().Get("window")
88114 if !window.Truthy() {
@@ -119,73 +145,126 @@ func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
119145 return nil, errors.New("webkit message handler not available")
120146 }
121147
122- type result struct {
123- body []byte
124- err error
148+ ensureWebKitCallback(window, plan.success, false)
149+ ensureWebKitCallback(window, plan.failure, true)
150+
151+ resultCh := make(chan webkitCallbackResult, 1)
152+ unregister, err := registerWebKitWaiter(req.requestID, plan, resultCh)
153+ if err != nil {
154+ return nil, err
125155 }
126- resultCh := make(chan result, 1)
127- var callbackMu sync.Mutex
128- callbacksActive := true
129- publish := func(res result) {
130- callbackMu.Lock()
131- active := callbacksActive
132- callbackMu.Unlock()
133- if !active {
134- return
135- }
136- select {
137- case resultCh <- res:
138- default:
139- }
156+
157+ handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
158+
159+ select {
160+ case res := <-resultCh:
161+ return res.body, res.err
162+ case <-ctx.Done():
163+ unregister()
164+ return nil, ctx.Err()
140165 }
166+}
141167
142- successFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
143- payload, err := stringifyArgs(args)
144- if err != nil {
145- publish(result{err: err})
146- return nil
147- }
148- normalized, err := normalizeNativeSuccessResponse(req.requestID, payload)
149- publish(result{body: normalized, err: err})
168+func ensureWebKitCallback(window js.Value, name string, failure bool) {
169+ webkitCallbacks.Lock()
170+ if _, ok := webkitCallbacks.installed[name]; ok {
171+ webkitCallbacks.Unlock()
172+ return
173+ }
174+ callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
175+ handleWebKitCallback(name, failure, args)
150176 return nil
151177 })
152- errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
153- payload, err := stringifyArgs(args)
154- if err != nil {
155- publish(result{err: err})
156- return nil
178+ webkitCallbacks.installed[name] = callback
179+ // Installed callbacks are page-lifetime dispatchers. Per-request waiters are
180+ // removed on completion/cancellation, so late native responses are dropped
181+ // without invoking released js.Func handles.
182+ 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)
157205 }
158- normalized, err := normalizeNativeErrorResponse(req.requestID, payload)
159- publish(result{body: normalized, err: err})
160- return nil
161- })
206+ if webkitCallbacks.waiters[plan.failure] == waiter {
207+ delete(webkitCallbacks.waiters, plan.failure)
208+ }
209+ }, nil
210+}
162211
163- previousSuccess := window.Get(plan.success)
164- previousError := window.Get(plan.failure)
165- window.Set(plan.success, successFn)
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- }()
212+func handleWebKitCallback(name string, failure bool, args []js.Value) {
213+ payload, payloadErr := stringifyArgs(args)
214+ payloadRequestID := requestIDFromBridgePayload(payload)
176215
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()
178228
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+ }
179242 select {
180- case res := <-resultCh:
181- return res.body, res.err
182- case <-ctx.Done():
183- return nil, ctx.Err()
243+ case waiter.resultCh <- res:
244+ default:
245+ warnDroppedBridgeResult("webkit callback", res.err)
184246 }
185247 }
186248
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+
187262 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))
189268 if err != nil {
190269 return nil, err
191270 }
@@ -243,43 +322,77 @@ func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, err
243322 }
244323
245324 func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
325+ if ctx == nil {
326+ ctx = context.Background()
327+ }
246328 type result struct {
247329 value js.Value
248330 err error
249331 }
250332 resultCh := make(chan result, 1)
251333
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+
252370 var (
253371 callbackMu sync.Mutex
254- active = true
255372 released bool
256- thenFn js.Func
257- catchFn js.Func
373+ thenCb js.Func
374+ catchCb js.Func
258375 )
259- releaseCallbacksLocked := func() {
376+ releaseCallbacks := func() {
377+ callbackMu.Lock()
378+ defer callbackMu.Unlock()
260379 if released {
261380 return
262381 }
263382 released = true
264- thenFn.Release()
265- catchFn.Release()
383+ thenCb.Release()
384+ catchCb.Release()
266385 }
267386 publish := func(res result) {
268- callbackMu.Lock()
269- defer callbackMu.Unlock()
270- if !active {
271- releaseCallbacksLocked()
272- return
273- }
274- active = false
275387 select {
276388 case resultCh <- res:
277389 default:
390+ warnDroppedBridgeResult("promise", res.err)
278391 }
279- releaseCallbacksLocked()
280392 }
281393
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()
283396 if len(args) == 0 {
284397 publish(result{value: js.Undefined()})
285398 return nil
@@ -287,31 +400,40 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
287400 publish(result{value: args[0]})
288401 return nil
289402 })
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()
291405 if len(args) == 0 {
292406 publish(result{err: errors.New("promise rejected")})
293407 return nil
294408 }
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)})
296415 return nil
297416 })
417+ attachPromiseCallbacks(racePromise, thenCb, catchCb)
298418
299- promise.Call("then", thenFn).Call("catch", catchFn)
419+ res := <-resultCh
420+ return res.value, res.err
421+}
300422
301- select {
302- case res := <-resultCh:
303- return res.value, res.err
304- case <-ctx.Done():
305- select {
306- case res := <-resultCh:
307- return res.value, res.err
308- default:
309- }
310- callbackMu.Lock()
311- active = false
312- callbackMu.Unlock()
313- return js.Undefined(), ctx.Err()
423+func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) {
424+ promise.Call("then", thenCb, catchCb)
425+}
426+
427+func warnDroppedBridgeResult(scope string, err error) {
428+ console := js.Global().Get("console")
429+ if !console.Truthy() || !console.Get("warn").Truthy() {
430+ return
314431 }
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")
315437 }
316438
317439 func stringifyArgs(args []js.Value) ([]byte, error) {
@@ -327,8 +449,11 @@ func stringifyValue(value js.Value) ([]byte, error) {
327449 return nil, errors.New("JSON global not available")
328450 }
329451 stringified := jsonValue.Call("stringify", value)
330- if !stringified.Truthy() {
331- return nil, errors.New("failed to stringify JS value")
452+ if stringified.Type() == js.TypeString {
453+ return []byte(stringified.String()), nil
454+ }
455+ if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) {
456+ return []byte("null"), nil
332457 }
333- return []byte(stringified.String()), nil
458+ return nil, errors.New("failed to stringify JS value")
334459 }
M internal/infrastructure/systemviewsbridge/browser_transport_test.go +78 -0
@@ -83,6 +83,40 @@ func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) {
8383 }
8484 }
8585
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+
86120 func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
87121 t.Parallel()
88122
@@ -106,6 +140,50 @@ func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
106140 }
107141 }
108142
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+
109187 func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {
110188 t.Parallel()
111189
M internal/infrastructure/systemviewsbridge/client.go +138 -27
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "strings"
89 "sync/atomic"
910 "time"
1011
@@ -20,8 +21,9 @@ type Transport interface {
2021
2122 // Client sends WebUI message envelopes through the first available transport.
2223 type Client struct {
23- native Transport
24- fetch Transport
24+ native Transport
25+ fetch Transport
26+ requestTimeout time.Duration
2527 }
2628
2729 var _ port.SystemviewConfigService = (*Client)(nil)
@@ -30,11 +32,27 @@ var _ port.SystemviewFavoritesService = (*Client)(nil)
3032
3133 var requestSeq atomic.Uint64
3234
33-var bridgeRequestTimeout = 15 * time.Second
35+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+}
3446
3547 // NewClient creates a bridge client with native WebKit and fetch transports.
36-func NewClient(native, fetch Transport) *Client {
37- return &Client{native: native, fetch: fetch}
48+func NewClient(native, fetch Transport, opts ...ClientOption) *Client {
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
3856 }
3957
4058 // 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,
5775 }
5876
5977 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+ }
6084 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
6185 RequestID string `json:"requestId"`
6286 Limit int `json:"limit"`
@@ -65,6 +89,16 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His
6589 }
6690
6791 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+ }
68102 return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
69103 RequestID string `json:"requestId"`
70104 Domain string `json:"domain"`
@@ -74,6 +108,9 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off
74108 }
75109
76110 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+ }
77114 return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
78115 RequestID string `json:"requestId"`
79116 Query string `json:"query"`
@@ -82,6 +119,9 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity
82119 }
83120
84121 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+ }
85125 _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
86126 RequestID string `json:"requestId"`
87127 ID int64 `json:"id"`
@@ -90,6 +130,10 @@ func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
90130 }
91131
92132 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+ }
93137 _, err := request[struct{}](c, ctx, "history_delete_range", struct {
94138 RequestID string `json:"requestId"`
95139 Range string `json:"range"`
@@ -97,12 +141,16 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
97141 return err
98142 }
99143
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.
100146 func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
101147 return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {
102148 RequestID string `json:"requestId"`
103149 }{RequestID: nextRequestID()})
104150 }
105151
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.
106154 func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
107155 return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
108156 RequestID string `json:"requestId"`
@@ -143,6 +191,9 @@ func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error
143191 }
144192
145193 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+ }
146197 return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
147198 RequestID string `json:"requestId"`
148199 Limit int `json:"limit"`
@@ -150,6 +201,10 @@ func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainSt
150201 }
151202
152203 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+ }
153208 _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
154209 RequestID string `json:"requestId"`
155210 Domain string `json:"domain"`
@@ -168,10 +223,16 @@ func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateIn
168223 }
169224
170225 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+ }
171229 return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
172230 }
173231
174232 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+ }
175236 _, err := request[struct{}](c, ctx, "favorite_delete", struct {
176237 RequestID string `json:"requestId"`
177238 ID int64 `json:"id"`
@@ -244,6 +305,9 @@ func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
244305 }
245306
246307 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+ }
247311 _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
248312 RequestID string `json:"requestId"`
249313 FavoriteID int64 `json:"favorite_id"`
@@ -253,6 +317,9 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey
253317 }
254318
255319 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+ }
256323 _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
257324 RequestID string `json:"requestId"`
258325 FavoriteID int64 `json:"favorite_id"`
@@ -261,19 +328,34 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6
261328 return err
262329 }
263330
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+ }
265340 return request[*entity.Folder](c, ctx, "folder_create", struct {
266341 RequestID string `json:"requestId"`
267342 Name string `json:"name"`
268343 Icon *string `json:"icon"`
269344 ParentID *int64 `json:"parent_id,omitempty"`
270- }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID})
345+ }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
271346 }
272347
273348 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+ }
274356 var iconPtr *string
275- if icon != "" {
276- iconPtr = &icon
357+ if trimmed := strings.TrimSpace(icon); trimmed != "" {
358+ iconPtr = &trimmed
277359 }
278360 _, err := request[struct{}](c, ctx, "folder_update", struct {
279361 RequestID string `json:"requestId"`
@@ -285,6 +367,9 @@ func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string)
285367 }
286368
287369 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+ }
288373 _, err := request[struct{}](c, ctx, "folder_delete", struct {
289374 RequestID string `json:"requestId"`
290375 ID int64 `json:"id"`
@@ -293,9 +378,13 @@ func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
293378 }
294379
295380 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+ }
296385 var colorPtr *string
297- if color != "" {
298- colorPtr = &color
386+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
387+ colorPtr = &trimmedColor
299388 }
300389 return request[*entity.Tag](c, ctx, "tag_create", struct {
301390 RequestID string `json:"requestId"`
@@ -305,13 +394,16 @@ func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag
305394 }
306395
307396 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+ }
308400 var namePtr *string
309- if name != "" {
310- namePtr = &name
401+ if trimmedName := strings.TrimSpace(name); trimmedName != "" {
402+ namePtr = &trimmedName
311403 }
312404 var colorPtr *string
313- if color != "" {
314- colorPtr = &color
405+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
406+ colorPtr = &trimmedColor
315407 }
316408 _, err := request[struct{}](c, ctx, "tag_update", struct {
317409 RequestID string `json:"requestId"`
@@ -323,6 +415,9 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er
323415 }
324416
325417 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+ }
326421 _, err := request[struct{}](c, ctx, "tag_delete", struct {
327422 RequestID string `json:"requestId"`
328423 ID int64 `json:"id"`
@@ -331,6 +426,12 @@ func (c *Client) DeleteTag(ctx context.Context, id int64) error {
331426 }
332427
333428 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+ }
334435 _, err := request[struct{}](c, ctx, "tag_assign", struct {
335436 RequestID string `json:"requestId"`
336437 FavoriteID int64 `json:"favorite_id"`
@@ -340,6 +441,12 @@ func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
340441 }
341442
342443 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+ }
343450 _, err := request[struct{}](c, ctx, "tag_remove", struct {
344451 RequestID string `json:"requestId"`
345452 FavoriteID int64 `json:"favorite_id"`
@@ -364,7 +471,7 @@ func (c *Client) transport() Transport {
364471 func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
365472 var zero T
366473
367- ctx, cancel := withBridgeRequestTimeout(ctx)
474+ ctx, cancel := c.withBridgeRequestTimeout(ctx)
368475 defer cancel()
369476
370477 raw, err := c.Send(ctx, msgType, payload)
@@ -375,14 +482,18 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any)
375482 return decodeBridgeResponse[T](raw)
376483 }
377484
378-func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
485+func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
379486 if ctx == nil {
380487 ctx = context.Background()
381488 }
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 {
383494 return context.WithCancel(ctx)
384495 }
385- return context.WithTimeout(ctx, bridgeRequestTimeout)
496+ return context.WithTimeout(ctx, timeout)
386497 }
387498
388499 type bridgeResponse struct {
@@ -393,27 +504,27 @@ type bridgeResponse struct {
393504 }
394505
395506 func decodeBridgeResponse[T any](body []byte) (T, error) {
396- var zero T
507+ var result T
397508
398509 var resp bridgeResponse
399510 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)
401512 }
402513 if !resp.Success {
403514 if resp.Error == "" {
404- return zero, errors.New("bridge request failed")
515+ return result, errors.New("bridge request failed")
405516 }
406- return zero, errors.New(resp.Error)
517+ return result, errors.New(resp.Error)
407518 }
408519 if len(resp.Data) == 0 {
409- return zero, nil
520+ return result, nil
410521 }
411522
412- if err := json.Unmarshal(resp.Data, &zero); err != nil {
413- return zero, fmt.Errorf("unmarshal bridge data: %w", err)
523+ if err := json.Unmarshal(resp.Data, &result); err != nil {
524+ return result, fmt.Errorf("unmarshal bridge data: %w", err)
414525 }
415526
416- return zero, nil
527+ return result, nil
417528 }
418529
419530 func nextRequestID() string {
M internal/infrastructure/systemviewsbridge/client_test.go +227 -8
@@ -126,6 +126,21 @@ func TestClientTimelineDecodesEntries(t *testing.T) {
126126 }
127127 }
128128
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+
129144 func TestClientDeleteRangeSendsRange(t *testing.T) {
130145 t.Parallel()
131146
@@ -357,12 +372,10 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
357372 }
358373
359374 func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
360- oldTimeout := bridgeRequestTimeout
361- bridgeRequestTimeout = time.Millisecond
362- t.Cleanup(func() { bridgeRequestTimeout = oldTimeout })
375+ t.Parallel()
363376
364377 transport := &blockingTransport{available: true}
365- client := NewClient(transport, nil)
378+ client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond))
366379
367380 _, err := client.Timeline(context.Background(), 25, 0)
368381 if !errors.Is(err, context.DeadlineExceeded) {
@@ -377,10 +390,10 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) {
377390 t.Parallel()
378391
379392 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}}`)}
381394 client := NewClient(transport, nil)
382395
383- folder, err := client.CreateFolder(context.Background(), "Nested", &parentID)
396+ folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
384397 if err != nil {
385398 t.Fatalf("CreateFolder() error = %v", err)
386399 }
@@ -390,6 +403,9 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) {
390403 if folder.Name != "Nested" {
391404 t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
392405 }
406+ if folder.Icon != "📁" {
407+ t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
408+ }
393409 if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
394410 t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
395411 }
@@ -417,14 +433,155 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) {
417433 if payload.Name != "Nested" {
418434 t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
419435 }
420- if payload.Icon != nil {
421- t.Fatalf("sent icon = %#v, want nil", payload.Icon)
436+ if payload.Icon == nil || *payload.Icon != "📁" {
437+ t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
422438 }
423439 if payload.RequestID == "" {
424440 t.Fatal("sent requestId was empty")
425441 }
426442 }
427443
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+
428585 type fakeTransport struct {
429586 available bool
430587 called bool
@@ -452,3 +609,65 @@ func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error)
452609 <-ctx.Done()
453610 return nil, ctx.Err()
454611 }
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 (
55 "errors"
66 "fmt"
77 "io"
8+ "net"
89 "net/http"
10+ "net/url"
911 "sort"
1012 "strings"
11- "unsafe"
12-
13- "net"
1413 "time"
14+ "unsafe"
1515
1616 "github.com/bnema/purego"
1717 "github.com/rs/zerolog"
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/purego-ffmpeg/ffmpeg"
2020
2121 "github.com/bnema/dumber/internal/application/port"
22+ "github.com/bnema/dumber/internal/infrastructure/netguard"
2223 )
2324
2425 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
@@ -53,10 +54,104 @@ type pipeline struct {
5354 // are not killed. The session context handles cancellation.
5455 var httpClient = &http.Client{
5556 Transport: &http.Transport{
56- DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
57+ DialContext: safeTranscodeDialContext,
5758 TLSHandshakeTimeout: 10 * time.Second,
5859 ResponseHeaderTimeout: 15 * time.Second,
5960 },
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")
60155 }
61156
62157 func newPipeline(
@@ -394,7 +489,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI
394489
395490 func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {
396491 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")
398497 }
399498 return p.openCustomIOInputContext(ctx)
400499 }
@@ -417,6 +516,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
417516 _ = resp.Body.Close()
418517 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)
419518 }
519+ if isStreamingManifestResponse(resp) {
520+ _ = resp.Body.Close()
521+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
522+ }
420523
421524 body := resp.Body
422525 readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
M internal/infrastructure/webkit/scheme_handler.go +56 -17
@@ -1,16 +1,19 @@
11 package webkit
22
33 import (
4+ "bytes"
45 "context"
56 "embed"
67 "fmt"
8+ "io"
79 "io/fs"
810 "net/http"
911 "net/url"
10- "path/filepath"
12+ "path"
1113 "strings"
1214 "sync"
1315
16+ "github.com/andybalholm/brotli"
1417 "github.com/bnema/dumber/internal/infrastructure/webutil"
1518 "github.com/bnema/dumber/internal/logging"
1619 "github.com/bnema/puregotk/v4/gio"
@@ -21,13 +24,15 @@ import (
2124
2225 // Scheme path constants
2326 const (
24- HistoryPath = "history"
25- FavoritesPath = "favorites"
26- ConfigPath = "config"
27- ErrorPath = "error"
28- CrashPath = "crash"
29- IndexHTML = "index.html"
30- httpGET = "GET"
27+ HistoryPath = "history"
28+ FavoritesPath = "favorites"
29+ ConfigPath = "config"
30+ ErrorPath = "error"
31+ CrashPath = "crash"
32+ IndexHTML = "index.html"
33+ httpGET = "GET"
34+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
35+ systemviewsAssetDir = "systemviews"
3136 )
3237
3338 // SchemeRequest represents a request to a custom URI scheme.
@@ -76,7 +81,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
7681
7782 h := &DumbSchemeHandler{
7883 handlers: make(map[string]PageHandler),
79- assetDir: "systemviews",
84+ assetDir: systemviewsAssetDir,
8085 logger: log.With().Str("component", "scheme-handler").Logger(),
8186 }
8287
@@ -300,7 +305,11 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
300305 assetDir = h.assetDir
301306 }
302307
303- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
308+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
309+ if !ok {
310+ return nil
311+ }
312+
304313 data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath)
305314 if err != nil {
306315 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
@@ -322,10 +331,40 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
322331 }
323332 }
324333
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+
325357 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
326358 if strings.HasSuffix(relPath, ".wasm") {
327- if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
328- return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil
359+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
360+ 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
329368 }
330369 }
331370 data, err := fs.ReadFile(assets, fullPath)
@@ -341,10 +380,10 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
341380 assetDir string
342381 file string
343382 }{
344- HistoryPath: {assetDir: "systemviews", file: IndexHTML},
345- FavoritesPath: {assetDir: "systemviews", file: IndexHTML},
346- ConfigPath: {assetDir: "systemviews", file: IndexHTML},
347- ErrorPath: {assetDir: "systemviews", file: IndexHTML},
383+ HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
384+ FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
385+ ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
386+ ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
348387 }
349388
350389 if root, ok := rootByHost[u.Host]; ok {
@@ -357,7 +396,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
357396
358397 switch u.Opaque {
359398 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath:
360- return "systemviews", IndexHTML, true
399+ return systemviewsAssetDir, IndexHTML, true
361400 default:
362401 return "", "", false
363402 }
M internal/infrastructure/webkit/scheme_handler_test.go +55 -0
@@ -114,6 +114,7 @@ func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
114114
115115 resp := h.handleAsset(u)
116116 require.NotNil(t, resp)
117+ // Charset suffixes are acceptable; assert the MIME type contract only.
117118 assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
118119 assert.Contains(t, string(resp.Data), ".sv-app")
119120 }
@@ -213,3 +214,57 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
213214 assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
214215 assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
215216 }
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 (
99 // GetMimeType determines the MIME type for a given file path.
1010 // It tries the standard library first, then falls back to common web asset types.
1111 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+ }
1317
1418 mt := mime.TypeByExtension(ext)
1519 if mt != "" {
M internal/infrastructure/webutil/mime_test.go +23 -3
@@ -2,10 +2,30 @@ package webutil
22
33 import "testing"
44
5-func TestGetMimeType_WASM(t *testing.T) {
5+func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) {
66 t.Parallel()
77
8- if got := GetMimeType("systemviews.wasm"); got != "application/wasm" {
9- t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm")
8+ tests := []struct {
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+ })
1030 }
1131 }
M internal/ui/coordinator/workspace.go +19 -3
@@ -231,13 +231,13 @@ func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, target
231231 }
232232 if c.getActiveWS == nil {
233233 log.Warn().Str("url", targetURL).Msg("active workspace provider not configured")
234- return nil
234+ return fmt.Errorf("system view toggle: active workspace provider not configured")
235235 }
236236
237237 ws, wsView := c.getActiveWS()
238238 if ws == nil {
239239 log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle")
240- return nil
240+ return fmt.Errorf("system view toggle: no active workspace")
241241 }
242242
243243 activePane := ws.ActivePane()
@@ -284,6 +284,10 @@ func (c *WorkspaceCoordinator) focusExistingPane(
284284 ) {
285285 oldActivePaneID := ws.ActivePaneID
286286 ws.ActivePaneID = paneID
287+ paneNode := ws.FindPane(paneID)
288+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
289+ setActiveStackIndexForChild(paneNode.Parent, paneNode)
290+ }
287291
288292 if wsView != nil {
289293 wsView.CancelAllPendingHovers()
@@ -296,7 +300,7 @@ func (c *WorkspaceCoordinator) focusExistingPane(
296300 } else {
297301 wsView.FocusPane(paneID)
298302 }
299- if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
303+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
300304 c.syncStackedViewActive(ctx, wsView, paneNode)
301305 }
302306 }
@@ -304,6 +308,18 @@ func (c *WorkspaceCoordinator) focusExistingPane(
304308 c.notifyStateChanged()
305309 }
306310
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+
307323 func (c *WorkspaceCoordinator) prepareSplit(
308324 ctx context.Context,
309325 direction usecase.SplitDirection,
M internal/ui/coordinator/workspace_systemview_test.go +33 -0
@@ -53,6 +53,10 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin
5353 ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID}
5454
5555 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+ }),
5660 GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
5761 return ws, nil
5862 },
@@ -91,3 +95,32 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T
9195 require.NotNil(t, active)
9296 assert.Equal(t, "https://example.com", active.Pane.URI)
9397 }
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(
104104 Msg("registered global shortcut")
105105
106106 if workspace != nil {
107- // Map action names to action constants for global shortcuts
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- }
107+ actionMap := globalShortcutActionMap()
122108
123109 for actionName, actionBinding := range workspace.Shortcuts.Actions {
124110 action, ok := actionMap[actionName]
@@ -253,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
253239 return true
254240 }
255241
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+
256264 // ReloadShortcuts rebuilds global shortcuts from a new config.
257265 // It replaces the GTK shortcut controller to ensure stale shortcuts
258266 // are removed.
@@ -291,20 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
291299
292300 // Re-register config-driven shortcuts
293301 if workspace != nil {
294- actionMap := map[string]Action{
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- }
302+ actionMap := globalShortcutActionMap()
308303
309304 for actionName, actionBinding := range workspace.Shortcuts.Actions {
310305 action, ok := actionMap[actionName]
M internal/ui/input/shortcuts.go +19 -28
@@ -362,35 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
362362 // r = rename tab
363363 // This follows Zellij-style modal keyboard interface.
364364 //
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-
365+ // However, these standard browser shortcuts ARE global.
392366 for actionName, actionBinding := range cfg.Shortcuts.Actions {
393- action, ok := actionMap[actionName]
367+ action, ok := configActionToAction[actionName]
394368 if !ok {
395369 continue
396370 }
@@ -599,25 +573,42 @@ var configActionToAction = map[string]Action{
599573 "toggle-config-systemview": ActionToggleConfigSystemView,
600574
601575 // Tab actions
576+ "new_tab": ActionNewTab,
602577 "new-tab": ActionNewTab,
578+ "close_tab": ActionCloseTab,
603579 "close-tab": ActionCloseTab,
580+ "next_tab": ActionNextTab,
604581 "next-tab": ActionNextTab,
582+ "previous_tab": ActionPreviousTab,
605583 "previous-tab": ActionPreviousTab,
584+ "rename_tab": ActionRenameTab,
606585 "rename-tab": ActionRenameTab,
607586
608587 // Pane actions
588+ "split_right": ActionSplitRight,
609589 "split-right": ActionSplitRight,
590+ "split_left": ActionSplitLeft,
610591 "split-left": ActionSplitLeft,
592+ "split_up": ActionSplitUp,
611593 "split-up": ActionSplitUp,
594+ "split_down": ActionSplitDown,
612595 "split-down": ActionSplitDown,
596+ "close_pane": ActionClosePane,
613597 "close-pane": ActionClosePane,
598+ "stack_pane": ActionStackPane,
614599 "stack-pane": ActionStackPane,
600+ "move_pane_to_tab": ActionMovePaneToTab,
615601 "move-pane-to-tab": ActionMovePaneToTab,
602+ "move_pane_to_next_tab": ActionMovePaneToNextTab,
616603 "move-pane-to-next-tab": ActionMovePaneToNextTab,
617604
605+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
618606 "consume-or-expel-left": ActionConsumeOrExpelLeft,
607+ "consume_or_expel_right": ActionConsumeOrExpelRight,
619608 "consume-or-expel-right": ActionConsumeOrExpelRight,
609+ "consume_or_expel_up": ActionConsumeOrExpelUp,
620610 "consume-or-expel-up": ActionConsumeOrExpelUp,
611+ "consume_or_expel_down": ActionConsumeOrExpelDown,
621612 "consume-or-expel-down": ActionConsumeOrExpelDown,
622613
623614 // Focus navigation
M internal/ui/input/shortcuts_test.go +40 -0
@@ -402,9 +402,49 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
402402 }
403403 }
404404
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+
405424 func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
406425 action := mapConfigAction("toggle-floating-pane")
407426 if action != ActionToggleFloatingPane {
408427 t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
409428 }
410429 }
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 {
2020 }
2121
2222 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.
2325 mu sync.Mutex
2426 actionMu sync.Mutex
2527 deps Dependencies
@@ -46,12 +48,33 @@ type App struct {
4648 configError string
4749 renderedHTML string
4850 actionQueue chan DOMAction
51+ actionErrorQueue chan error
4952 actionCtx context.Context
5053 actionCancel context.CancelFunc
54+ actionClosed bool
55+ actionWG sync.WaitGroup
5156 }
5257
5358 const historyTimelineLimit = 25
5459
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+
5578 func NewApp(deps Dependencies) *App {
5679 return &App{deps: deps, currentRoute: RouteUnknown}
5780 }
@@ -65,16 +88,16 @@ func (a *App) RunWithContext(ctx context.Context) error {
6588 return errors.New("app is nil")
6689 }
6790 if ctx == nil {
68- ctx = context.Background()
91+ return errors.New("context is nil")
6992 }
7093
7194 a.currentRoute = ParseRoute(a.deps.LocationURI)
72- if err := a.LoadInitial(ctx); err != nil {
73- return err
74- }
7595 if a.deps.DOM == nil {
7696 return errors.New("DOM not configured")
7797 }
98+ if err := a.LoadInitial(ctx); err != nil {
99+ return err
100+ }
78101
79102 if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
80103 return err
@@ -86,43 +109,114 @@ func (a *App) RunWithContext(ctx context.Context) error {
86109 }
87110
88111 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+ }
90125 if a.actionQueue == nil {
91- workerCtx, cancel := context.WithCancel(ctx)
92- queue := make(chan DOMAction, 64)
126+ workerCtx, cancel = context.WithCancel(ctx)
127+ queue = make(chan DOMAction, 64)
128+ errorQueue = make(chan error, 1)
93129 a.actionQueue = queue
130+ a.actionErrorQueue = errorQueue
94131 a.actionCtx = workerCtx
95132 a.actionCancel = cancel
96- go a.runActionWorker(workerCtx, queue)
133+ created = true
97134 }
98- a.actionMu.Unlock()
99- return binder.BindActions(func(action DOMAction) {
135+ a.unlockAction()
136+
137+ if err := binder.BindActions(func(action DOMAction) {
100138 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()
102192 }
103- })
193+ close(queue)
194+ close(errorQueue)
195+ }
196+ a.unlockAction()
104197 }
105198
106199 func (a *App) actionWorkerActive() bool {
107200 if a == nil {
108201 return false
109202 }
110- a.actionMu.Lock()
203+ a.lockAction()
204+ closed := a.actionClosed
111205 queue := a.actionQueue
112206 ctx := a.actionCtx
113- a.actionMu.Unlock()
114- return queue != nil && ctx != nil && ctx.Err() == nil
207+ a.unlockAction()
208+ return !closed && queue != nil && ctx != nil && ctx.Err() == nil
115209 }
116210
117211 func (a *App) enqueueDOMAction(action DOMAction) bool {
118212 if a == nil {
119213 return false
120214 }
121- a.actionMu.Lock()
215+ a.lockAction()
216+ defer a.unlockAction()
122217 queue := a.actionQueue
123218 ctx := a.actionCtx
124- a.actionMu.Unlock()
125- if queue == nil || ctx == nil {
219+ if a.actionClosed || queue == nil || ctx == nil {
126220 return false
127221 }
128222 select {
@@ -135,45 +229,100 @@ func (a *App) enqueueDOMAction(action DOMAction) bool {
135229 }
136230 }
137231
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+
138251 func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
139252 for {
140253 select {
141254 case <-ctx.Done():
142255 return
143256 case action, ok := <-queue:
144- if !ok {
257+ if !ok || ctx.Err() != nil {
145258 return
146259 }
147260 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
149275 }
276+ a.surfaceActionError(ctx, err)
150277 }
151278 }
152279 }
153280
154-func (a *App) surfaceActionError(err error) {
281+func (a *App) surfaceActionError(ctx context.Context, err error) {
155282 if a == nil || err == nil {
156283 return
157284 }
158- a.mu.Lock()
285+ a.lockState()
159286 a.renderRouteError(err)
160- a.mu.Unlock()
161- _ = a.mountRenderedHTML()
287+ html := a.renderedHTML
288+ a.unlockState()
289+ if mountErr := a.mountHTML(html); mountErr != nil {
290+ logActionMountError(ctx, mountErr, err)
291+ }
162292 }
163293
164294 func (a *App) Close() {
165295 if a == nil {
166296 return
167297 }
168- a.actionMu.Lock()
298+ a.lockAction()
169299 cancel := a.actionCancel
300+ actionQueue := a.actionQueue
301+ actionErrorQueue := a.actionErrorQueue
302+ a.actionClosed = true
170303 a.actionCancel = nil
171304 a.actionCtx = nil
172305 a.actionQueue = nil
173- a.actionMu.Unlock()
306+ a.actionErrorQueue = nil
307+ a.unlockAction()
308+
174309 if cancel != nil {
175310 cancel()
176311 }
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+ }
177326 if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
178327 releaser.Release()
179328 }
@@ -200,6 +349,7 @@ func (a *App) LoadInitial(ctx context.Context) error {
200349 a.resetRouteState()
201350 a.renderedHTML = renderAppFrame(renderedPage{
202351 route: a.currentRoute,
352+ title: routeDocumentTitle(a.currentRoute),
203353 subtitle: string(a.currentRoute),
204354 body: placeholderHTML(a.currentRoute),
205355 }, a.shellTheme)
@@ -219,11 +369,25 @@ func (a *App) renderRouteError(err error) {
219369 }
220370 a.renderedHTML = renderAppFrame(renderedPage{
221371 route: a.currentRoute,
372+ title: routeDocumentTitle(a.currentRoute) + " — Error",
222373 subtitle: routeSubtitle(a.currentRoute),
223374 body: errorStateHTML(message),
224375 }, a.shellTheme)
225376 }
226377
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+
227391 func routeSubtitle(route Route) string {
228392 switch route {
229393 case RouteHistory:
@@ -242,6 +406,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
242406 a.resetRouteState()
243407 a.renderedHTML = renderAppFrame(renderedPage{
244408 route: a.currentRoute,
409+ title: routeDocumentTitle(RouteHistory),
245410 subtitle: "Recent visits",
246411 body: placeholderHTML(a.currentRoute),
247412 }, a.shellTheme)
@@ -266,20 +431,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error {
266431 a.historyEntries = entries
267432 a.historyAnalytics = analytics
268433 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+ }
269445 a.renderedHTML = renderAppFrame(renderedPage{
270446 route: RouteHistory,
447+ title: historyDocumentTitle(data),
271448 subtitle: "Recent visits",
272- body: historyHTML(historyRenderData{
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- }),
449+ body: historyHTML(data),
283450 }, a.shellTheme)
284451 return nil
285452 }
@@ -288,11 +455,7 @@ func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, e
288455 query := strings.TrimSpace(a.historyQuery)
289456 domain := strings.TrimSpace(a.historyDomainFilter)
290457 if query != "" {
291- entries, err := a.deps.History.Search(ctx, query, historyTimelineLimit)
292- if err != nil || domain == "" {
293- return entries, err
294- }
295- return filterHistoryEntriesByDomain(entries, domain), nil
458+ return a.deps.History.Search(ctx, query, historyTimelineLimit)
296459 }
297460 if domain != "" {
298461 return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset)
@@ -305,6 +468,7 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
305468 a.resetRouteState()
306469 a.renderedHTML = renderAppFrame(renderedPage{
307470 route: a.currentRoute,
471+ title: routeDocumentTitle(RouteFavorites),
308472 subtitle: "Saved bookmarks",
309473 body: placeholderHTML(a.currentRoute),
310474 }, a.shellTheme)
@@ -329,18 +493,20 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error {
329493 a.favorites = favorites
330494 a.folders = folders
331495 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+ }
332505 a.renderedHTML = renderAppFrame(renderedPage{
333506 route: RouteFavorites,
507+ title: favoritesDocumentTitle(data),
334508 subtitle: "Saved bookmarks",
335- body: favoritesHTML(favoritesRenderData{
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- }),
509+ body: favoritesHTML(data),
344510 }, a.shellTheme)
345511 return nil
346512 }
@@ -350,6 +516,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
350516 a.resetRouteState()
351517 a.renderedHTML = renderAppFrame(renderedPage{
352518 route: a.currentRoute,
519+ title: routeDocumentTitle(RouteConfig),
353520 subtitle: "Browser settings",
354521 body: placeholderHTML(a.currentRoute),
355522 }, a.shellTheme)
@@ -373,6 +540,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
373540 a.keybindings = keybindings
374541 a.renderedHTML = renderAppFrame(renderedPage{
375542 route: RouteConfig,
543+ title: "Config — Dumber",
376544 subtitle: "Browser settings",
377545 body: configHTML(configRenderData{
378546 Config: config,
M internal/ui/systemviews/app_test.go +224 -5
@@ -19,6 +19,11 @@ func TestParseRoute(t *testing.T) {
1919 uri string
2020 want Route
2121 }{
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},
2227 {name: "history host", uri: "dumb://history", want: RouteHistory},
2328 {name: "history opaque", uri: "dumb:history", want: RouteHistory},
2429 {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
@@ -81,6 +86,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
8186 assert.NotContains(t, app.renderedHTML, "<body")
8287 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
8388 assert.Contains(t, app.renderedHTML, `data-route="history"`)
89+ assert.Contains(t, app.renderedHTML, `data-page-title="History — 1 entry"`)
8490 assert.Contains(t, app.renderedHTML, `sv-section`)
8591 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
8692 }
@@ -133,11 +139,13 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
133139 assert.Contains(t, dom.html, "Search result")
134140 assert.Contains(t, dom.html, "Query: example")
135141
142+ app.historyOffset = historyTimelineLimit
136143 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
137144 Action: historyActionDeleteEntry,
138145 Data: map[string]string{"id": "42"},
139146 }))
140147 assert.Equal(t, int64(42), history.deletedEntryID)
148+ assert.Equal(t, 0, app.historyOffset)
141149 assert.Contains(t, dom.html, "Deleted history entry")
142150
143151 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -154,6 +162,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
154162 }))
155163 assert.True(t, history.domainCalled)
156164 assert.Equal(t, "example.com", history.domain)
165+ assert.Empty(t, app.historyQuery)
157166
158167 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
159168 Action: historyActionDeleteDomain,
@@ -181,6 +190,37 @@ func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
181190 })
182191 }
183192
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+
184224 func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
185225 t.Parallel()
186226
@@ -278,6 +318,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
278318 assert.NotContains(t, app.renderedHTML, "<body")
279319 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
280320 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
321+ assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
281322 assert.Contains(t, app.renderedHTML, `sv-section`)
282323 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
283324 }
@@ -306,6 +347,8 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
306347 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
307348 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
308349 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"`)
309352 assert.Contains(t, app.renderedHTML, "Read Later")
310353 assert.Contains(t, app.renderedHTML, "Go")
311354 assert.Contains(t, app.renderedHTML, "Shortcut 3")
@@ -322,11 +365,12 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
322365
323366 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
324367 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"},
326369 }))
327370 require.NotNil(t, favorites.createdFavorite.FolderID)
328371 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
329372 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
373+ assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
330374 assert.Contains(t, dom.html, "Added favorite New")
331375
332376 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -362,6 +406,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
362406 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
363407 assert.Nil(t, app.favoriteFolderFilter)
364408
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+
365417 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
366418 Action: favoriteActionDelete,
367419 Data: map[string]string{"id": "42"},
@@ -492,6 +544,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
492544 assert.NotContains(t, app.renderedHTML, "<body")
493545 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
494546 assert.Contains(t, app.renderedHTML, `data-route="config"`)
547+ assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
495548 assert.Contains(t, app.renderedHTML, `sv-section`)
496549 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
497550 }
@@ -580,12 +633,20 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
580633 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
581634 assert.Contains(t, dom.html, "Saved performance settings")
582635
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+
583643 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
584644 Action: configActionSetKeybinding,
585645 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
586646 }))
587647 assert.True(t, service.calledSet)
588648 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
649+ assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
589650 assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")
590651
591652 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -594,6 +655,8 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
594655 }))
595656 assert.True(t, service.calledReset)
596657 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)
597660 assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")
598661
599662 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
@@ -601,6 +664,27 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
601664 assert.Contains(t, dom.html, "Reset all keybindings to defaults")
602665 }
603666
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+
604688 func testConfigPayload() port.SystemviewConfigPayload {
605689 return port.SystemviewConfigPayload{
606690 EngineType: "webkit",
@@ -639,9 +723,9 @@ type fakeDOM struct {
639723 html string
640724 }
641725
642-func (d *fakeDOM) Mount(html string) error {
726+func (d *fakeDOM) Mount(markup string) error {
643727 d.mounted = true
644- d.html = html
728+ d.html = markup
645729 return nil
646730 }
647731
@@ -660,6 +744,15 @@ func (d *fakeActionDOM) Release() {
660744 d.released = true
661745 }
662746
747+type failingActionDOM struct {
748+ fakeDOM
749+ bindErr error
750+}
751+
752+func (d *failingActionDOM) BindActions(DOMActionHandler) error {
753+ return d.bindErr
754+}
755+
663756 // Handwritten fake to capture history state for stateful render assertions.
664757 type fakeHistoryService struct {
665758 called bool
@@ -742,6 +835,7 @@ type fakeFavoritesService struct {
742835 updatedFavorite port.FavoriteUpdateInput
743836 deletedFavorite int64
744837 createdFolder string
838+ createdFolderIcon string
745839 updatedFolderID int64
746840 deletedFolderID int64
747841 createdTag string
@@ -787,9 +881,10 @@ func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error {
787881
788882 func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
789883
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) {
791885 s.createdFolder = name
792- return &entity.Folder{ID: 77, Name: name}, nil
886+ s.createdFolderIcon = icon
887+ return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
793888 }
794889
795890 func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
@@ -897,3 +992,127 @@ func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
897992 s.calledResetAll = true
898993 return nil
899994 }
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 @@
11 package systemviews
22
33 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) }
55 if theme.InlineVars != "" {
66 style={ theme.InlineVars }
77 }
@@ -38,7 +38,7 @@ templ Alert(kind string, message string) {
3838 }
3939 }
4040
41-templ List(emptyMessage string) {
41+templ List() {
4242 <ul class="sv-list">
4343 { children... }
4444 </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) {
4747 templ PaletteFields(title string, prefix string, palette port.ColorPalette) {
4848 <fieldset class="sv-fieldset">
4949 <legend>{ title }</legend>
50- <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>
51- <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>
52- <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>
53- <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>
54- <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>
55- <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>
56- <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } 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="color" value={ palette.Surface } 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="color" value={ palette.Text } 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="color" value={ palette.Accent } required/></label>
56+ <label><span>Border</span><input name={ prefix + "_border" } type="color" value={ palette.Border } required/></label>
5757 </fieldset>
5858 }
5959
@@ -67,7 +67,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) {
6767 <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>
6868 <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
6969 <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>
7171 <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
7272 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
7373 </form>
@@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
8989 <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">
9090 <input type="hidden" name="key" value={ shortcut.Key }/>
9191 <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>
9393 <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>
9494 <button class="sv-button sv-button-secondary" type="submit">Save</button>
9595 <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) {
9898 }
9999
100100 templ PerformanceSettings(config port.SystemviewConfigPayload) {
101+ {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
101102 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
102103 <label><span>Profile</span><select name="profile">
103104 <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
@@ -105,11 +106,12 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) {
105106 <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
106107 <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
107108 </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- <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>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>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>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label>
109+ <p class="sv-meta">Choose Custom to edit individual performance values.</p>
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>
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>
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>
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>
113115 <button class="sv-button" type="submit">Save performance</button>
114116 </form>
115117 <div class="sv-stat-grid">
@@ -183,7 +185,7 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding
183185 <form class="sv-inline-form" data-sv-action="config.keybinding.set">
184186 <input type="hidden" name="mode" value={ group.Mode }/>
185187 <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>
187189 <button class="sv-button sv-button-secondary" type="submit">Save</button>
188190 <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>
189191 </form>
M internal/ui/systemviews/config_actions.go +154 -31
@@ -3,26 +3,39 @@ package systemviews
33 import (
44 "context"
55 "fmt"
6+ "math"
7+ "net/url"
68 "strconv"
79 "strings"
10+ "sync/atomic"
811
912 "github.com/bnema/dumber/internal/application/port"
1013 )
1114
1215 const (
13- configActionSaveAppearance = "config.appearance.save"
14- configActionResetAppearance = "config.appearance.reset"
15- configActionSaveSearch = "config.search.save"
16- configActionCreateSearchShortcut = "config.searchShortcut.create"
17- configActionUpdateSearchShortcut = "config.searchShortcut.update"
18- configActionDeleteSearchShortcut = "config.searchShortcut.delete"
19- configActionSavePerformance = "config.performance.save"
20- configActionSetKeybinding = "config.keybinding.set"
21- configActionResetKeybinding = "config.keybinding.reset"
22- configActionResetAllKeybindings = "config.keybinding.resetAll"
23- configRequestIDPrefix = "systemviews-config"
16+ configActionSaveAppearance = "config.appearance.save"
17+ configActionResetAppearance = "config.appearance.reset"
18+ configActionSaveSearch = "config.search.save"
19+ configActionCreateSearchShortcut = "config.searchShortcut.create"
20+ configActionUpdateSearchShortcut = "config.searchShortcut.update"
21+ configActionDeleteSearchShortcut = "config.searchShortcut.delete"
22+ configActionSavePerformance = "config.performance.save"
23+ configActionSetKeybinding = "config.keybinding.set"
24+ configActionResetKeybinding = "config.keybinding.reset"
25+ configActionResetAllKeybindings = "config.keybinding.resetAll"
26+ configRequestIDPrefix = "systemviews-config"
27+ maxCustomPerformanceThreads = 8
28+ maxCustomPerformanceWebMemoryMB = 16384
29+ maxCustomPerformanceNetworkMemoryMB = 4096
30+ maxCustomPerformancePrewarm = 20
2431 )
2532
33+var configRequestCounter atomic.Uint64
34+
35+func nextConfigRequestID() string {
36+ return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1))
37+}
38+
2639 func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
2740 if a.deps.Config == nil {
2841 return fmt.Errorf("config service not configured")
@@ -52,8 +65,10 @@ func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
5265 return err
5366 }
5467 a.configNotice = "Reset all keybindings to defaults"
68+ return nil
69+ default:
70+ return fmt.Errorf("unknown config action: %q", event.Action)
5571 }
56- return nil
5772 }
5873
5974 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)
7085 if err != nil {
7186 return err
7287 }
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+ }
7394
7495 cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
7596 cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
7697 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
7798 cfg.Appearance.DefaultFontSize = fontSize
7899 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
79- cfg.Appearance.LightPalette = paletteFromForm(data, "light")
80- cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
100+ lightPalette, err := paletteFromForm(data, "light")
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
81110 cfg.DefaultUIScale = uiScale
82111
83112 if err := a.saveEditableConfig(ctx, cfg); err != nil {
@@ -110,7 +139,11 @@ func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) erro
110139 if err != nil {
111140 return err
112141 }
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
114147 if err := a.saveEditableConfig(ctx, cfg); err != nil {
115148 return err
116149 }
@@ -127,6 +160,9 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string)
127160 if key == "" {
128161 return fmt.Errorf("search shortcut key is required")
129162 }
163+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
164+ return err
165+ }
130166 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
131167 if _, exists := cfg.SearchShortcuts[key]; exists {
132168 return fmt.Errorf("search shortcut %q already exists", key)
@@ -152,6 +188,9 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string)
152188 if newKey == "" {
153189 return fmt.Errorf("new search shortcut key is required")
154190 }
191+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
192+ return err
193+ }
155194 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
156195 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
157196 return fmt.Errorf("search shortcut %q not found", oldKey)
@@ -197,6 +236,15 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
197236 return err
198237 }
199238 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+
200248 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
201249 if err != nil {
202250 return err
@@ -217,8 +265,22 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
217265 if err != nil {
218266 return err
219267 }
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+ }
220283
221- cfg.Performance.Profile = profile
222284 cfg.Performance.Custom = port.WebUICustomPerformanceConfig{
223285 SkiaCPUThreads: skiaCPU,
224286 SkiaGPUThreads: skiaGPU,
@@ -244,7 +306,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e
244306 return err
245307 }
246308 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
247- RequestID: configRequestIDPrefix,
309+ RequestID: nextConfigRequestID(),
248310 Mode: mode,
249311 Action: action,
250312 Keys: keys,
@@ -270,7 +332,7 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
270332 return err
271333 }
272334 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
273- RequestID: configRequestIDPrefix,
335+ RequestID: nextConfigRequestID(),
274336 Mode: mode,
275337 Action: action,
276338 }); err != nil {
@@ -344,16 +406,57 @@ func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]p
344406 return clone
345407 }
346408
347-func paletteFromForm(data map[string]string, prefix string) port.ColorPalette {
348- return port.ColorPalette{
349- Background: strings.TrimSpace(data[prefix+"_background"]),
350- Surface: strings.TrimSpace(data[prefix+"_surface"]),
351- SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]),
352- Text: strings.TrimSpace(data[prefix+"_text"]),
353- Muted: strings.TrimSpace(data[prefix+"_muted"]),
354- Accent: strings.TrimSpace(data[prefix+"_accent"]),
355- Border: strings.TrimSpace(data[prefix+"_border"]),
409+func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette, error) {
410+ color := func(field string) (string, error) {
411+ value := strings.TrimSpace(data[prefix+"_"+field])
412+ if value == "" || isValidHexColor(value) {
413+ return value, nil
414+ }
415+ return "", fmt.Errorf("%s_%s must be a hex color", prefix, field)
416+ }
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
356441 }
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))
357460 }
358461
359462 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
363466 }
364467 }
365468
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+
366484 func parseConfigInt(raw, label string) (int, error) {
367485 value, err := strconv.Atoi(strings.TrimSpace(raw))
368486 if err != nil {
@@ -379,15 +497,20 @@ func parseConfigFloat(raw, label string) (float64, error) {
379497 return value, nil
380498 }
381499
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+
382507 func parseKeyList(raw string) []string {
383508 raw = strings.TrimSpace(raw)
384509 if raw == "" {
385510 return nil
386511 }
387- parts := strings.Split(raw, ",")
388- if len(parts) == 1 {
389- parts = strings.Fields(raw)
390- }
512+ raw = strings.ReplaceAll(raw, ",", " ")
513+ parts := strings.Fields(raw)
391514 keys := make([]string, 0, len(parts))
392515 for _, part := range parts {
393516 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 {
1616 Error string
1717 }
1818
19+// maxFlattenDepth caps recursive config flattening to avoid runaway nesting.
1920 const maxFlattenDepth = 16
2021
2122 func configHTML(data configRenderData) string {
@@ -253,6 +254,9 @@ func formatFlatValue(value reflect.Value) string {
253254 case reflect.Complex64, reflect.Complex128:
254255 return fmt.Sprint(value.Complex())
255256 default:
257+ if !value.CanInterface() {
258+ return "<unexported>"
259+ }
256260 return fmt.Sprint(value.Interface())
257261 }
258262 }
@@ -275,7 +279,10 @@ func mapKeyString(value reflect.Value) string {
275279 if value.Kind() == reflect.String {
276280 return value.String()
277281 }
278- return fmt.Sprint(value.Interface())
282+ if value.CanInterface() {
283+ return fmt.Sprint(value.Interface())
284+ }
285+ return formatFlatValue(value)
279286 }
280287
281288 func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {
@@ -344,5 +351,5 @@ func plural(count int, singular string) string {
344351 if count == 1 {
345352 return singular
346353 }
347- return strings.TrimSpace(singular + "s")
354+ return singular + "s"
348355 }
M internal/ui/systemviews/dom.go +1 -1
@@ -6,7 +6,7 @@ import (
66 )
77
88 type DOM interface {
9- Mount(html string) error
9+ Mount(markup string) error
1010 }
1111
1212 // 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 {
2828 }
2929
3030 target := doc.Call("getElementById", "app")
31- if !target.Truthy() {
32- target = doc.Get("body")
33- }
34-
3531 return &browserDOM{document: doc, target: target}
3632 }
3733
38-func (d *browserDOM) Mount(html string) error {
34+func (d *browserDOM) Mount(markup string) error {
3935 if d == nil || !d.target.Truthy() {
40- return fmt.Errorf("DOM target not available")
36+ return fmt.Errorf("DOM mount target #app not found")
4137 }
42- d.target.Set("innerHTML", html)
38+ d.target.Set("innerHTML", markup)
39+ d.updateDocumentTitle()
40+ d.scheduleAlertDismissal()
4341 return nil
4442 }
4543
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+
46104 func (d *browserDOM) BindActions(handler DOMActionHandler) error {
47105 if d == nil || !d.target.Truthy() || handler == nil {
48106 return fmt.Errorf("DOM action binding target not available")
@@ -58,6 +116,15 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
58116 if !element.Truthy() {
59117 return nil
60118 }
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+ }
61128 action := element.Call("getAttribute", "data-sv-action").String()
62129 if action == "" {
63130 return nil
@@ -84,6 +151,9 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
84151 if action == "" {
85152 return nil
86153 }
154+ if !confirmAction(form) {
155+ return nil
156+ }
87157 event.Call("preventDefault")
88158 data := collectActionData(form)
89159 for key, value := range collectFormData(form) {
@@ -95,7 +165,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
95165 d.addEventBinding(d.target, "submit", submitHandler)
96166
97167 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)
99173 return nil
100174 })
101175 d.addEventBinding(d.document, "keydown", keydownHandler)
@@ -103,6 +177,26 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error {
103177 return nil
104178 }
105179
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+
106200 func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
107201 if !target.Truthy() {
108202 fn.Release()
@@ -172,7 +266,13 @@ func collectFormData(form js.Value) map[string]string {
172266 if pair.Get("length").Int() < 2 {
173267 continue
174268 }
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
176276 }
177277 return data
178278 }
@@ -232,6 +332,8 @@ func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
232332 }
233333 }
234334 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.
235337 if row := d.activeHistoryRow(); row.Truthy() {
236338 event.Call("preventDefault")
237339 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) {
349451 func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
350452 rows := d.target.Call("querySelectorAll", selector)
351453 length := rows.Get("length").Int()
352- if length == 0 {
454+ if length <= 0 {
353455 return js.Value{}
354456 }
355457 idx := d.activeIndex(datasetKey)
356458 if idx < 0 || idx >= length {
357459 idx = 0
460+ row := rows.Index(idx)
358461 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
360464 }
361465 return rows.Index(idx)
362466 }
M internal/ui/systemviews/favorite_actions.go +72 -4
@@ -3,6 +3,7 @@ package systemviews
33 import (
44 "context"
55 "fmt"
6+ "net/url"
67 "strconv"
78 "strings"
89
@@ -38,10 +39,19 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
3839 if err != nil {
3940 return err
4041 }
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+ }
4150 favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{
42- URL: strings.TrimSpace(data["url"]),
51+ URL: favoriteURL,
4352 Title: strings.TrimSpace(data["title"]),
4453 FolderID: folderID,
54+ Tags: tags,
4555 })
4656 if err != nil {
4757 return err
@@ -106,7 +116,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
106116 if name == "" {
107117 return fmt.Errorf("folder name is required")
108118 }
109- folder, err := a.deps.Favorites.CreateFolder(ctx, name, nil)
119+ folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
110120 if err != nil {
111121 return err
112122 }
@@ -168,11 +178,11 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
168178 a.favoriteTagFilter = nil
169179 a.favoritesNotice = "Deleted tag"
170180 case tagActionAssign, tagActionRemove:
171- favoriteID, err := parsePositiveInt64(data["favoriteId"], "favorite id")
181+ favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
172182 if err != nil {
173183 return err
174184 }
175- tagID, err := parsePositiveInt64(data["tagId"], "tag id")
185+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
176186 if err != nil {
177187 return err
178188 }
@@ -187,10 +197,40 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
187197 }
188198 a.favoritesNotice = "Removed tag"
189199 }
200+ default:
201+ return fmt.Errorf("unknown favorite action: %q", event.Action)
190202 }
191203 return nil
192204 }
193205
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+
194234 func firstActionValue(data map[string]string, keys ...string) string {
195235 for _, key := range keys {
196236 if value, ok := data[key]; ok {
@@ -208,6 +248,31 @@ func parsePositiveInt64(raw, label string) (int64, error) {
208248 return id, nil
209249 }
210250
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.
211276 func optionalFolderID(raw string) (*entity.FolderID, error) {
212277 raw = strings.TrimSpace(raw)
213278 if raw == "" || raw == "root" {
@@ -221,6 +286,8 @@ func optionalFolderID(raw string) (*entity.FolderID, error) {
221286 return &folderID, nil
222287 }
223288
289+// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
290+// favorites in the root folder; other inputs delegate to optionalFolderID.
224291 func filterFolderID(raw string) (*entity.FolderID, error) {
225292 if strings.TrimSpace(raw) == "root" {
226293 root := entity.FolderID(0)
@@ -229,6 +296,7 @@ func filterFolderID(raw string) (*entity.FolderID, error) {
229296 return optionalFolderID(raw)
230297 }
231298
299+// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
232300 func optionalShortcut(raw string) (*int, error) {
233301 raw = strings.TrimSpace(raw)
234302 if raw == "" {
M internal/ui/systemviews/favorites.templ +23 -4
@@ -62,16 +62,17 @@ templ FavoritesControls(data favoritesRenderData) {
6262 <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
6363 <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
6464 @FolderSelect("folder_id", data.Folders, nil, "Folder")
65+ @FavoriteCreateTagPicker(data.Tags)
6566 <button class="sv-button" type="submit">Add favorite</button>
6667 </form>
6768 <div class="sv-filter-panel">
6869 <div class="sv-button-row">
6970 <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(favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>
71+ <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</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>
7273 for _, folder := range data.Folders {
7374 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>
7576 }
7677 }
7778 </div>
@@ -79,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) {
7980 <span class="sv-meta">Tags</span>
8081 for _, tag := range data.Tags {
8182 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) }>
8384 <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }
8485 </button>
8586 }
@@ -89,6 +90,24 @@ templ FavoritesControls(data favoritesRenderData) {
8990 <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>
9091 }
9192
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+
92111 templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
93112 <li class="sv-list-row">
94113 <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 {
2121 return mustRenderComponent(FavoritesView(data))
2222 }
2323
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+
2438 func favoritesSummary(data favoritesRenderData) string {
2539 return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
2640 }
@@ -68,7 +82,7 @@ func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) strin
6882 if favorite == nil {
6983 return ""
7084 }
71- parts := []string{}
85+ parts := make([]string, 0, 2)
7286 if favorite.ShortcutKey != nil {
7387 parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
7488 }
@@ -96,9 +110,9 @@ func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string {
96110 }
97111
98112 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"
100114 if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
101- classes += " sv-button-active"
115+ classes += " sv-button-active sv-tag-button-active"
102116 }
103117 return classes
104118 }
M internal/ui/systemviews/history_actions.go +30 -3
@@ -25,8 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
2525 if a == nil {
2626 return fmt.Errorf("app is nil")
2727 }
28- a.mu.Lock()
29- defer a.mu.Unlock()
28+ a.lockState()
29+ defer a.unlockState()
3030 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
3131 a.currentRoute = ParseRoute(a.deps.LocationURI)
3232 }
@@ -40,6 +40,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
4040 }
4141 if err := a.loadHistoryRoute(ctx); err != nil {
4242 a.renderRouteError(err)
43+ _ = a.mountRenderedHTML()
44+ return err
4345 }
4446 return a.mountRenderedHTML()
4547 case RouteFavorites:
@@ -50,6 +52,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
5052 }
5153 if err := a.loadFavoritesRoute(ctx); err != nil {
5254 a.renderRouteError(err)
55+ _ = a.mountRenderedHTML()
56+ return err
5357 }
5458 return a.mountRenderedHTML()
5559 case RouteConfig:
@@ -61,6 +65,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
6165 a.loadShellTheme(ctx)
6266 if err := a.loadConfigRoute(ctx); err != nil {
6367 a.renderRouteError(err)
68+ _ = a.mountRenderedHTML()
69+ return err
6470 }
6571 return a.mountRenderedHTML()
6672 default:
@@ -77,6 +83,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
7783 switch event.Action {
7884 case historyActionSearch:
7985 a.historyQuery = strings.TrimSpace(data["query"])
86+ a.historyDomainFilter = ""
8087 a.historyOffset = 0
8188 a.historyNotice = ""
8289 case historyActionClear:
@@ -90,6 +97,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
9097 return fmt.Errorf("domain is required")
9198 }
9299 a.historyDomainFilter = domain
100+ a.historyQuery = ""
93101 a.historyOffset = 0
94102 a.historyNotice = ""
95103 case historyActionClearDomain:
@@ -111,12 +119,16 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
111119 if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
112120 return err
113121 }
122+ a.historyOffset = 0
114123 a.historyNotice = "Deleted history entry"
115124 case historyActionDeleteRange:
116125 rangeID := strings.TrimSpace(data["range"])
117126 if rangeID == "" {
118127 return fmt.Errorf("history range is required")
119128 }
129+ if !isKnownHistoryCleanupRange(rangeID) {
130+ return fmt.Errorf("invalid history range")
131+ }
120132 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
121133 return err
122134 }
@@ -135,15 +147,30 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
135147 }
136148 a.historyOffset = 0
137149 a.historyNotice = "Deleted history for " + domain
150+ default:
151+ return fmt.Errorf("unknown history action: %q", event.Action)
138152 }
139153 return nil
140154 }
141155
142156 func (a *App) mountRenderedHTML() error {
157+ return a.mountHTML(a.renderedHTML)
158+}
159+
160+func (a *App) mountHTML(html string) error {
143161 if a.deps.DOM == nil {
144162 return nil
145163 }
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
147174 }
148175
149176 func historyRangeNotice(rangeID string) string {
M internal/ui/systemviews/history_view.go +40 -29
@@ -38,11 +38,41 @@ func historyHTML(data historyRenderData) string {
3838 return mustRenderComponent(HistoryView(data))
3939 }
4040
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+
4171 var historyCleanupRanges = []historyCleanupItem{
42- {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?", Notice: "Deleted history from the last hour"},
43- {Label: "Today", RangeID: "day", Confirm: "Delete history for today?", Notice: "Deleted history from today"},
44- {Label: "Week", RangeID: "week", Confirm: "Delete history for week?", Notice: "Deleted history from this week"},
45- {Label: "Month", RangeID: "month", Confirm: "Delete history for month?", Notice: "Deleted history from this month"},
72+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"},
73+ {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"},
74+ {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"},
75+ {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"},
4676 {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
4777 }
4878
@@ -103,11 +133,6 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i
103133 visits = sumHistoryVisits(data.Entries)
104134 uniqueDays = countUniqueHistoryDays(data.Entries)
105135
106- if data.Analytics != nil {
107- entries = data.Analytics.TotalEntries
108- visits = data.Analytics.TotalVisits
109- uniqueDays = data.Analytics.UniqueDays
110- }
111136 return entries, visits, uniqueDays
112137 }
113138
@@ -121,11 +146,12 @@ func historyTimelineEmptyMessage(data historyRenderData) string {
121146 func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
122147 groups := make([]historyTimelineGroup, 0)
123148 indexByDate := map[string]int{}
149+ now := time.Now().Local()
124150 for _, entry := range entries {
125151 if entry == nil {
126152 continue
127153 }
128- dateKey, label := historyDateKeyAndLabel(entry.LastVisited)
154+ dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now)
129155 idx, ok := indexByDate[dateKey]
130156 if !ok {
131157 idx = len(groups)
@@ -137,14 +163,14 @@ func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup
137163 return groups
138164 }
139165
140-func historyDateKeyAndLabel(ts time.Time) (string, string) {
166+func historyDateKeyAndLabel(ts, now time.Time) (string, string) {
141167 if ts.IsZero() {
142168 return "unknown", "Unknown date"
143169 }
144170
145171 local := ts.Local()
146172 dateKey := local.Format("2006-01-02")
147- today := time.Now().Local()
173+ today := now.Local()
148174 todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
149175 yesterdayStart := todayStart.AddDate(0, 0, -1)
150176 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 {
175201 if entry == nil {
176202 continue
177203 }
204+ // Each HistoryEntry represents at least one visit, even when VisitCount
205+ // is unset or non-positive in older persisted rows.
178206 if entry.VisitCount <= 0 {
179207 visits++
180208 continue
@@ -195,23 +223,6 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
195223 return int64(len(seen))
196224 }
197225
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-
215226 func displayHistoryDomain(raw string) string {
216227 return browserurl.DisplayDomain(raw)
217228 }
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 (
77
88 func sanitizeHref(raw string) string {
99 trimmed := strings.TrimSpace(raw)
10- if trimmed == "" {
10+ if trimmed == "" || strings.HasPrefix(trimmed, "//") {
1111 return "#"
1212 }
1313
@@ -24,7 +24,16 @@ func sanitizeHref(raw string) string {
2424 }
2525
2626 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+ }
2837 return trimmed
2938 default:
3039 return "#"
M internal/ui/systemviews/href_test.go +18 -0
@@ -34,6 +34,24 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
3434 assert.Contains(t, html, `href="https://example.com"`)
3535 }
3636
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+
3755 func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {
3856 t.Parallel()
3957
M internal/ui/systemviews/prefers_dark_js.go +5 -1
@@ -16,5 +16,9 @@ var currentPrefersDarkImpl = func() bool {
1616 }
1717
1818 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()
2024 }
M internal/ui/systemviews/render.go +2 -0
@@ -19,6 +19,8 @@ func renderComponent(ctx context.Context, component templ.Component) (string, er
1919 }
2020
2121 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.
2224 html, err := renderComponent(context.Background(), component)
2325 if err != nil {
2426 return errorStateHTML(err.Error())
M internal/ui/systemviews/shell.go +17 -8
@@ -17,16 +17,28 @@ type kvPair struct {
1717 // fragment. Route views populate this; the app frame wraps it.
1818 type renderedPage struct {
1919 route Route
20+ title string
2021 subtitle string
2122 body string
2223 }
2324
2425 // 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.
2628 func renderAppFrame(page renderedPage, theme shellTheme) string {
2729 return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))
2830 }
2931
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+
3042 func appRootClass(theme shellTheme) string {
3143 themeClass := theme.RootClass
3244 if themeClass == "" {
@@ -78,6 +90,7 @@ func listHTML(rows, emptyMessage string) string {
7890 return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)
7991 }
8092
93+// listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.
8194 func listRowHTML(inner string) string {
8295 return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner)
8396 }
@@ -109,15 +122,11 @@ func kvRowHTML(label, value string) string {
109122 return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))
110123 }
111124
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.
113127 func sectionHTML(class, title, inner string) string {
114- classes := "sv-section"
115- if strings.TrimSpace(class) != "" {
116- classes += " " + strings.TrimSpace(class)
117- }
118-
119128 return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,
120- html.EscapeString(classes),
129+ sectionClass(class),
121130 html.EscapeString(title),
122131 inner,
123132 )
M internal/ui/systemviews/shell_test.go +47 -1
@@ -26,8 +26,9 @@ func TestRenderAppFrame_ReturnsFragment(t *testing.T) {
2626 // Root div carries theme class.
2727 assert.Contains(t, out, `class="sv-app sv-dark"`)
2828
29- // Route is on the root element.
29+ // Route and document title are on the root element.
3030 assert.Contains(t, out, `data-route="history"`)
31+ assert.Contains(t, out, `data-page-title="History"`)
3132
3233 // Shell wrapper present.
3334 assert.Contains(t, out, `class="sv-shell"`)
@@ -52,6 +53,23 @@ func TestRenderAppFrame_DefaultThemeClass(t *testing.T) {
5253
5354 assert.Contains(t, out, `class="sv-app sv-dark"`)
5455 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")
5573 }
5674
5775 func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {
@@ -112,6 +130,34 @@ func TestKVHTMLRendersPairs(t *testing.T) {
112130 assert.Contains(t, out, "webkit")
113131 }
114132
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+
115161 func TestSectionHTML(t *testing.T) {
116162 t.Parallel()
117163
M internal/ui/systemviews/theme.go +112 -12
@@ -3,6 +3,7 @@ package systemviews
33 import (
44 "fmt"
55 "strings"
6+ "unicode"
67
78 "github.com/bnema/dumber/internal/application/port"
89 )
@@ -17,6 +18,15 @@ const (
1718 shellLightClass = "sv-light"
1819 )
1920
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+
2030 func currentPrefersDark() bool {
2131 return currentPrefersDarkImpl()
2232 }
@@ -54,23 +64,24 @@ func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
5464
5565 func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {
5666 parts := make([]string, 0, 11)
57- appendInlineVar := func(name, value string) {
58- if strings.TrimSpace(value) == "" {
67+ appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
68+ value, ok := sanitize(value)
69+ if !ok {
5970 return
6071 }
6172 parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
6273 }
6374
64- appendInlineVar("--sv-background", palette.Background)
65- appendInlineVar("--sv-surface", palette.Surface)
66- appendInlineVar("--sv-surface-variant", palette.SurfaceVariant)
67- appendInlineVar("--sv-text", palette.Text)
68- appendInlineVar("--sv-muted", palette.Muted)
69- appendInlineVar("--sv-accent", palette.Accent)
70- appendInlineVar("--sv-border", palette.Border)
71- appendInlineVar("--sv-font-sans", appearance.SansFont)
72- appendInlineVar("--sv-font-serif", appearance.SerifFont)
73- appendInlineVar("--sv-font-mono", appearance.MonospaceFont)
75+ appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor)
76+ appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor)
77+ appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor)
78+ appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor)
79+ appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor)
80+ appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor)
81+ appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor)
82+ appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily)
83+ appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily)
84+ appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily)
7485 if appearance.DefaultFontSize > 0 {
7586 parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
7687 }
@@ -78,6 +89,95 @@ func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPa
7889 return strings.Join(parts, " ")
7990 }
8091
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+
81181 func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {
82182 return appearance.ColorScheme == "" &&
83183 appearance.SansFont == "" &&
M internal/ui/systemviews/theme_test.go +60 -0
@@ -7,6 +7,66 @@ import (
77 "github.com/stretchr/testify/assert"
88 )
99
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+
1070 func TestResolveShellTheme(t *testing.T) {
1171 orig := currentPrefersDarkImpl
1272 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