detached b360a0d

Merge pull request #235 from bnema/feat/systemviews-wasm

Replace webui with WASM systemviews
commit
b360a0ded8b348f678185eec66248f0f7cb0ae8c
author
bnema · · 2026-04-26 18:17 UTC
committer
GitHub · · 2026-04-26 18:17 UTC
merge of
35d15a2 + c4ed781 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
260 files · +21413 -17442
M .github/dependabot.yml +0 -9
@@ -9,15 +9,6 @@ updates:
9 patterns:9 patterns:
10 - "*"10 - "*"
11 11
12- - package-ecosystem: npm
13- directory: /webui
14- schedule:
15- interval: weekly
16- groups:
17- npm-dependencies:
18- patterns:
19- - "*"
20-
21 - package-ecosystem: github-actions12 - package-ecosystem: github-actions
22 directory: /13 directory: /
23 schedule:14 schedule:
M .github/workflows/aur.yml +1 -1
@@ -51,7 +51,7 @@ jobs:
51 "arch=('x86_64')" \51 "arch=('x86_64')" \
52 'url="https://github.com/bnema/dumber"' \52 'url="https://github.com/bnema/dumber"' \
53 "license=('MIT')" \53 "license=('MIT')" \
54- "makedepends=('go>=1.25' 'npm' 'git')" \54+ "makedepends=('go>=1.26' 'git' 'brotli')" \
55 "depends=('gtk4' 'webkitgtk-6.0')" \55 "depends=('gtk4' 'webkitgtk-6.0')" \
56 'optdepends=(' \56 'optdepends=(' \
57 " 'gst-plugins-base: Base media codecs'" \57 " 'gst-plugins-base: Base media codecs'" \
M .github/workflows/ci.yml +8 -22
@@ -7,11 +7,11 @@ on:
7 - '**.go'7 - '**.go'
8 - 'go.mod'8 - 'go.mod'
9 - 'go.sum'9 - 'go.sum'
10- - 'webui/**'
11 - '.golangci.yml'10 - '.golangci.yml'
12 - 'Makefile'11 - 'Makefile'
13 - 'sqlc.yaml'12 - 'sqlc.yaml'
14 - 'assets/**'13 - 'assets/**'
14+ - '.github/workflows/**'
15 pull_request:15 pull_request:
16 16
17 jobs:17 jobs:
@@ -31,11 +31,11 @@ jobs:
31 - '**.go'31 - '**.go'
32 - 'go.mod'32 - 'go.mod'
33 - 'go.sum'33 - 'go.sum'
34- - 'webui/**'
35 - '.golangci.yml'34 - '.golangci.yml'
36 - 'Makefile'35 - 'Makefile'
37 - 'sqlc.yaml'36 - 'sqlc.yaml'
38 - 'assets/**'37 - 'assets/**'
38+ - '.github/workflows/**'
39 39
40 lint:40 lint:
41 needs: changes41 needs: changes
@@ -47,7 +47,7 @@ jobs:
47 - name: Install dependencies47 - name: Install dependencies
48 run: |48 run: |
49 pacman -Syu --noconfirm49 pacman -Syu --noconfirm
50- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel50+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
51 - uses: actions/checkout@v651 - uses: actions/checkout@v6
52 - name: Cache Go modules52 - name: Cache Go modules
53 uses: actions/cache@v553 uses: actions/cache@v5
@@ -63,15 +63,8 @@ jobs:
63 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}63 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
64 restore-keys: |64 restore-keys: |
65 ${{ runner.os }}-go-build-65 ${{ runner.os }}-go-build-
66- - name: Cache npm66+ - name: Build application and systemviews assets
67- uses: actions/cache@v567+ run: make build
68- with:
69- path: ~/.npm
70- key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }}
71- restore-keys: |
72- ${{ runner.os }}-npm-
73- - name: Build frontend assets
74- run: make build-frontend
75 - name: Install golangci-lint68 - name: Install golangci-lint
76 run: |69 run: |
77 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest70 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest
@@ -90,7 +83,7 @@ jobs:
90 - name: Install dependencies83 - name: Install dependencies
91 run: |84 run: |
92 pacman -Syu --noconfirm85 pacman -Syu --noconfirm
93- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel86+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli
94 - uses: actions/checkout@v687 - uses: actions/checkout@v6
95 - name: Cache Go modules88 - name: Cache Go modules
96 uses: actions/cache@v589 uses: actions/cache@v5
@@ -106,15 +99,8 @@ jobs:
106 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}99 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
107 restore-keys: |100 restore-keys: |
108 ${{ runner.os }}-go-build-101 ${{ runner.os }}-go-build-
109- - name: Cache npm102+ - name: Build application and systemviews assets
110- uses: actions/cache@v5103+ run: make build
111- with:
112- path: ~/.npm
113- key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }}
114- restore-keys: |
115- ${{ runner.os }}-npm-
116- - name: Build frontend assets
117- run: make build-frontend
118 - name: Run tests104 - name: Run tests
119 env:105 env:
120 GDK_BACKEND: wayland106 GDK_BACKEND: wayland
D .github/workflows/webui.yml +0 -30
@@ -1,30 +0,0 @@
1-name: WebUI CI
2-
3-on:
4- push:
5- branches: [dev, main]
6- paths:
7- - 'webui/**'
8- pull_request:
9- paths:
10- - 'webui/**'
11-
12-jobs:
13- check:
14- runs-on: ubuntu-latest
15- steps:
16- - uses: actions/checkout@v6
17-
18- - uses: actions/setup-node@v6
19- with:
20- node-version: '24'
21- cache: 'npm'
22- cache-dependency-path: 'webui/package-lock.json'
23-
24- - name: Install dependencies
25- run: npm ci
26- working-directory: webui
27-
28- - name: Run checks
29- run: npm run check
30- working-directory: webui
M .gitignore +3 -0
@@ -64,6 +64,9 @@ tmp/go-cache/
64 tmp/go-mod/64 tmp/go-mod/
65 tmp/65 tmp/
66 assets/webui66 assets/webui
67+assets/systemviews/systemviews.wasm
68+assets/systemviews/systemviews.wasm.br
69+assets/systemviews/wasm_exec.js
67 70
68 # Node/TypeScript related71 # Node/TypeScript related
69 node_modules/72 node_modules/
M .goreleaser.yaml +2 -2
@@ -4,8 +4,8 @@ version: 2
4 4
5 before:5 before:
6 hooks:6 hooks:
7- # Build frontend assets before building binaries7+ # Build systemviews assets before building binaries
8- - make build-frontend8+ - make build-systemviews
9 9
10 builds:10 builds:
11 # Pure Go build (no CGO required)11 # Pure Go build (no CGO required)
M .mockery.yaml +1 -0
@@ -88,6 +88,7 @@ packages:
88 UpdateChecker: {}88 UpdateChecker: {}
89 UpdateDownloader: {}89 UpdateDownloader: {}
90 WebUIConfigSaver: {}90 WebUIConfigSaver: {}
91+ SystemviewConfigReader: {}
91 XDGPaths: {}92 XDGPaths: {}
92 github.com/bnema/dumber/internal/domain/repository:93 github.com/bnema/dumber/internal/domain/repository:
93 config:94 config:
M CONTRIBUTING.md +3 -4
@@ -34,10 +34,9 @@ git push origin my-fix
34 2. Run `make test` and ensure tests pass34 2. Run `make test` and ensure tests pass
35 3. Run `go fmt` on any Go code changes35 3. Run `go fmt` on any Go code changes
36 36
37-**WebUI code (in `webui/`):**37+**Systemviews assets:**
38-1. Run `npm run lint` to fix linting issues38+1. If you modified systemviews files such as `cmd/systemviews/` or `.templ` components, run `make build-systemviews` to regenerate embedded WASM assets
39-2. Run `npm run fmt` to format with Prettier39+2. Run `make check` to verify build, tests, and tools before committing
40-3. Run `npm run check` to run svelte-check
41 40
42 Keep commits focused and use single-line commit messages.41 Keep commits focused and use single-line commit messages.
43 42
M Makefile +22 -14
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check3+.PHONY: build build-systemviews generate-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -39,22 +39,31 @@ help: ## Show this help message
39 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)39 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
40 40
41 # Build targets41 # Build targets
42-build: build-frontend ## Build the application (pure Go, no CGO)42+build: build-systemviews ## Build the application (pure Go, no CGO)
43 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."43 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
44 @mkdir -p $(DIST_DIR)44 @mkdir -p $(DIST_DIR)
45- CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)45+ CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
46 @rm -f $(DIST_DIR)/cef-helper46 @rm -f $(DIST_DIR)/cef-helper
47 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"47 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
48 48
49-build-frontend: ## Build homepage and error pages49+generate-systemviews: ## Generate Go code from systemviews templ components
50- @echo "Building webui pages (homepage + error)..."50+ @echo "Generating systemviews templ components..."
51- @cd webui && npm install --silent && npm run build51+ go tool templ generate -path internal/ui/systemviews -include-version=false
52- @echo "Frontend build complete"52+ @echo "Systemviews templ generation complete"
53-53+
54-build-quick: ## Build without frontend (faster for backend development)54+build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
55- @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."55+ @echo "Building systemviews wasm assets..."
56+ @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
57+ @mkdir -p assets/systemviews
58+ @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
59+ GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
60+ brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
61+ @echo "Systemviews build complete"
62+
63+build-quick: ## Build quickly for backend development
64+ @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
56 @mkdir -p $(DIST_DIR)65 @mkdir -p $(DIST_DIR)
57- CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)66+ CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
58 @rm -f $(DIST_DIR)/cef-helper67 @rm -f $(DIST_DIR)/cef-helper
59 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"68 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
60 69
@@ -137,8 +146,7 @@ clean: ## Clean build artifacts
137 @echo "Cleaning build artifacts..."146 @echo "Cleaning build artifacts..."
138 rm -rf $(DIST_DIR)147 rm -rf $(DIST_DIR)
139 rm -f $(BINARY_NAME)148 rm -f $(BINARY_NAME)
140- rm -rf webui/dist webui/node_modules149+ rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br
141- rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css
142 rm -f coverage.out coverage.html150 rm -f coverage.out coverage.html
143 go clean -cache151 go clean -cache
144 go clean -testcache152 go clean -testcache
@@ -174,7 +182,7 @@ man: build-quick ## Install man pages to ~/.local/share/man/man1/
174 # Native release targets182 # Native release targets
175 .PHONY: release-snapshot release183 .PHONY: release-snapshot release
176 184
177-release-snapshot: build-frontend ## Build snapshot using goreleaser185+release-snapshot: build-systemviews ## Build snapshot using goreleaser
178 @echo "Building snapshot with goreleaser..."186 @echo "Building snapshot with goreleaser..."
179 goreleaser release --snapshot --clean187 goreleaser release --snapshot --clean
180 188
A assets/systemviews/index.html +71 -0
@@ -0,0 +1,71 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <meta name="color-scheme" content="dark light">
7+ <title>systemviews</title>
8+ <link rel="stylesheet" href="./systemviews.css">
9+ <script src="./wasm_exec.js"></script>
10+ <script>
11+ function renderWasmError(prefixText, err) {
12+ const render = () => {
13+ const app = document.getElementById('app');
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+ }
63+ </script>
64+</head>
65+<body>
66+ <main id="app">Loading systemviews…</main>
67+ <noscript>
68+ <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p>
69+ </noscript>
70+</body>
71+</html>
A assets/systemviews/systemviews.css +977 -0
@@ -0,0 +1,977 @@
1+:root {
2+ color-scheme: dark;
3+ --sv-background: #0f1115;
4+ --sv-surface: #171a21;
5+ --sv-surface-variant: #1d2129;
6+ --sv-text: #e8ebf0;
7+ --sv-muted: #9aa3b2;
8+ --sv-border: #2a313d;
9+ --sv-success: var(--sv-accent, #8ab4ff);
10+ --sv-danger: #ef4444;
11+ --sv-danger-text: #fecaca;
12+ --sv-radius-card: 0.5rem;
13+ --sv-radius-control: 0.35rem;
14+ --sv-radius-chip: 0.45rem;
15+}
16+
17+body {
18+ margin: 0;
19+ font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20+ font-size: var(--sv-font-size, 14px);
21+ background: var(--sv-background, #0f1115);
22+ color: var(--sv-text, #e8ebf0);
23+}
24+
25+.sv-app {
26+ min-height: 100vh;
27+ background: var(--sv-background, #0f1115);
28+ color: var(--sv-text, #e8ebf0);
29+}
30+
31+.sv-app.sv-dark {
32+ color-scheme: dark;
33+}
34+
35+.sv-app.sv-light {
36+ color-scheme: light;
37+ --sv-background: #f8fafc;
38+ --sv-surface: #ffffff;
39+ --sv-surface-variant: #eef2f7;
40+ --sv-text: #111827;
41+ --sv-muted: #64748b;
42+ --sv-border: #d7dee8;
43+ --sv-primary: #2563eb;
44+ --sv-danger: #dc2626;
45+ --sv-danger-text: #7f1d1d;
46+}
47+
48+
49+.sv-shell {
50+ display: grid;
51+ gap: 1rem;
52+ padding: 1rem;
53+}
54+
55+.sv-title {
56+ display: flex;
57+ flex-wrap: wrap;
58+ gap: 0.45rem;
59+ align-items: baseline;
60+ margin: 0;
61+ font-size: clamp(1.35rem, 2.4vw, 2rem);
62+ line-height: 1.15;
63+}
64+
65+.sv-title-detail {
66+ color: var(--sv-muted);
67+ font-size: clamp(0.9rem, 1.25vw, 1.05rem);
68+ font-weight: 600;
69+ letter-spacing: 0;
70+}
71+
72+.sv-content {
73+ display: grid;
74+ gap: 1rem;
75+}
76+
77+.sv-section > h2 {
78+ margin: 0 0 0.75rem;
79+ font-size: 1rem;
80+ letter-spacing: 0.02em;
81+}
82+
83+.sv-list {
84+ display: grid;
85+ gap: 0.6rem;
86+ margin: 0.75rem 0 0;
87+ padding: 0;
88+ list-style: none;
89+}
90+
91+.sv-list-row {
92+ padding: 0.75rem;
93+ border: 1px solid var(--sv-border, #2a313d);
94+ border-radius: var(--sv-radius-card, 0.5rem);
95+ background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);
96+}
97+
98+.sv-link {
99+ color: var(--sv-accent, #8ab4ff);
100+ font-weight: 700;
101+ text-decoration: none;
102+}
103+
104+.sv-link:hover {
105+ text-decoration: underline;
106+}
107+
108+.sv-badge {
109+ display: inline-flex;
110+ align-items: center;
111+ border: 1px solid var(--sv-border, #2a313d);
112+ border-radius: 999px;
113+ padding: 0.15rem 0.45rem;
114+ background: var(--sv-surface-variant, #1d2129);
115+ color: var(--sv-muted, #9aa3b2);
116+ font-size: 12px;
117+ font-weight: 700;
118+}
119+
120+.sv-header {
121+ background: var(--sv-surface-variant, #1d2129);
122+ border: 1px solid var(--sv-border, #2a313d);
123+ border-radius: var(--sv-radius-card, 0.5rem);
124+ padding: 1rem;
125+}
126+
127+.sv-section {
128+ background: var(--sv-surface, #171a21);
129+ border: 1px solid var(--sv-border, #2a313d);
130+ border-radius: var(--sv-radius-card, 0.5rem);
131+ padding: 1rem;
132+}
133+
134+.sv-meta {
135+ color: var(--sv-muted, #9aa3b2);
136+ font-size: 12px;
137+}
138+
139+.sv-group {
140+ margin-top: 1rem;
141+ padding: 1rem;
142+ border: 1px solid var(--sv-border, #2a313d);
143+ border-radius: var(--sv-radius-card, 0.5rem);
144+ background: var(--sv-surface, #171a21);
145+}
146+
147+.sv-group-header {
148+ display: flex;
149+ justify-content: space-between;
150+ gap: 1rem;
151+ align-items: baseline;
152+}
153+
154+.sv-group-header h4 {
155+ margin: 0;
156+}
157+
158+.sv-group-body {
159+ display: grid;
160+ gap: 0.75rem;
161+ margin-top: 0.75rem;
162+}
163+
164+.sv-binding {
165+ padding-top: 0.75rem;
166+ border-top: 1px solid var(--sv-border, #2a313d);
167+ display: grid;
168+ gap: 0.5rem;
169+}
170+
171+.sv-group-body > .sv-binding:first-child {
172+ padding-top: 0;
173+ border-top: 0;
174+}
175+
176+.sv-binding-header {
177+ display: flex;
178+ justify-content: space-between;
179+ gap: 1rem;
180+ align-items: flex-start;
181+}
182+
183+.sv-binding-description {
184+ font-weight: 600;
185+}
186+
187+.sv-binding-keys {
188+ display: flex;
189+ flex-wrap: wrap;
190+ gap: 0.35rem;
191+ justify-content: flex-end;
192+}
193+
194+.sv-key {
195+ display: inline-flex;
196+ align-items: center;
197+ padding: 0.2rem 0.45rem;
198+ border-radius: var(--sv-radius-control, 0.35rem);
199+ border: 1px solid var(--sv-border, #2a313d);
200+ background: var(--sv-surface-variant, #1d2129);
201+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
202+ font-size: 12px;
203+}
204+
205+.sv-binding-meta {
206+ display: flex;
207+ flex-wrap: wrap;
208+ gap: 0.35rem 1rem;
209+ color: var(--sv-muted, #9aa3b2);
210+ font-size: 12px;
211+}
212+
213+.sv-empty {
214+ color: var(--sv-muted, #9aa3b2);
215+}
216+
217+.sv-loading-panel,
218+.sv-loading-card {
219+ display: inline-flex;
220+ align-items: center;
221+ gap: 0.55rem;
222+ color: var(--sv-muted, #9aa3b2);
223+}
224+
225+.sv-loading-panel {
226+ min-height: 4rem;
227+ border: 1px solid var(--sv-border, #2a313d);
228+ border-radius: var(--sv-radius-card, 0.5rem);
229+ padding: 0.85rem;
230+ background: var(--sv-surface-variant, #1d2129);
231+}
232+
233+.sv-spinner {
234+ width: 1rem;
235+ height: 1rem;
236+ border: 2px solid color-mix(in srgb, var(--sv-muted, #9aa3b2) 35%, transparent);
237+ border-top-color: var(--sv-accent, #8ab4ff);
238+ border-radius: 999px;
239+ animation: sv-spin 0.75s linear infinite;
240+}
241+
242+.sv-history-load-more {
243+ display: flex;
244+ justify-content: center;
245+ padding: 1rem 0 0.25rem;
246+}
247+
248+@keyframes sv-spin {
249+ to {
250+ transform: rotate(360deg);
251+ }
252+}
253+
254+.sv-stat-grid {
255+ display: grid;
256+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
257+ gap: 0.75rem;
258+ margin-top: 0.75rem;
259+}
260+
261+.sv-stat-card {
262+ display: grid;
263+ gap: 0.15rem;
264+ padding: 0.75rem;
265+ border: 1px solid var(--sv-border, #2a313d);
266+ border-radius: var(--sv-radius-card, 0.5rem);
267+ background: var(--sv-surface-variant, #1d2129);
268+}
269+
270+.sv-stat-value {
271+ color: var(--sv-accent, #8ab4ff);
272+ font-size: 1.35rem;
273+ font-weight: 700;
274+ line-height: 1.05;
275+ font-variant-numeric: tabular-nums;
276+}
277+
278+.sv-stat-label {
279+ color: var(--sv-muted, #9aa3b2);
280+ font-size: 12px;
281+ text-transform: uppercase;
282+ letter-spacing: 0.08em;
283+}
284+
285+.sv-domain-list {
286+ display: flex;
287+ flex-wrap: wrap;
288+ gap: 0.35rem;
289+ max-height: 4rem;
290+ margin-top: 0.5rem;
291+ overflow: hidden;
292+}
293+
294+.sv-domain-chip {
295+ display: inline-flex;
296+ gap: 0.35rem;
297+ align-items: center;
298+ max-width: 13rem;
299+ min-width: 0;
300+ padding: 0.25rem 0.35rem 0.25rem 0.5rem;
301+ border: 1px solid var(--sv-border, #2a313d);
302+ border-radius: var(--sv-radius-chip, 0.45rem);
303+ background: var(--sv-surface-variant, #1d2129);
304+}
305+
306+.sv-domain-chip-main {
307+ display: inline-flex;
308+ gap: 0.3rem;
309+ align-items: baseline;
310+ min-width: 0;
311+}
312+
313+.sv-domain-meta {
314+ flex: 0 0 auto;
315+ white-space: nowrap;
316+}
317+
318+.sv-domain-meta,
319+.sv-history-meta,
320+.sv-history-url {
321+ color: var(--sv-muted, #9aa3b2);
322+ font-size: 12px;
323+}
324+
325+.sv-timeline-group + .sv-timeline-group {
326+ margin-top: 1rem;
327+}
328+
329+.sv-timeline-group h3 {
330+ margin: 0 0 0.5rem;
331+ color: var(--sv-muted, #9aa3b2);
332+ font-size: 12px;
333+ letter-spacing: 0.08em;
334+ text-transform: uppercase;
335+}
336+
337+.sv-history-item {
338+ display: grid;
339+ grid-template-columns: minmax(0, 1fr) auto;
340+ gap: 1rem;
341+ align-items: start;
342+}
343+
344+.sv-history-content {
345+ display: grid;
346+ grid-template-columns: 24px minmax(0, 1fr);
347+ gap: 0.75rem;
348+ align-items: start;
349+ min-width: 0;
350+}
351+
352+.sv-history-favicon {
353+ position: relative;
354+ display: inline-grid;
355+ place-items: center;
356+ width: 24px;
357+ height: 24px;
358+ overflow: hidden;
359+ color: var(--sv-muted, #9aa3b2);
360+ font-size: 0.75rem;
361+ font-weight: 800;
362+ line-height: 1;
363+ text-transform: uppercase;
364+}
365+
366+.sv-history-favicon img {
367+ position: absolute;
368+ inset: 0;
369+ z-index: 1;
370+ width: 24px;
371+ height: 24px;
372+ object-fit: contain;
373+}
374+
375+.sv-history-favicon-fallback {
376+ position: relative;
377+ z-index: 0;
378+}
379+
380+.sv-history-favicon-has-image .sv-history-favicon-fallback {
381+ visibility: hidden;
382+}
383+
384+.sv-history-main {
385+ min-width: 0;
386+}
387+
388+.sv-history-url,
389+.sv-history-meta {
390+ margin: 0.25rem 0 0;
391+}
392+
393+.sv-history-url {
394+ display: -webkit-box;
395+ max-width: 100%;
396+ overflow: hidden;
397+ overflow-wrap: anywhere;
398+ -webkit-box-orient: vertical;
399+ -webkit-line-clamp: 2;
400+ line-clamp: 2;
401+}
402+
403+.sv-app.sv-dark .sv-history-meta {
404+ color: var(--sv-text, #e8ebf0);
405+}
406+
407+.sv-error {
408+ border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d));
409+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
410+ border-radius: var(--sv-radius-card, 0.5rem);
411+ color: var(--sv-text, #e8ebf0);
412+ padding: 1rem;
413+}
414+
415+.sv-error p {
416+ margin: 0;
417+}
418+
419+.sv-error p + p {
420+ margin-top: 0.5rem;
421+}
422+
423+.sv-kv {
424+ margin: 0.75rem 0 0;
425+ display: grid;
426+ gap: 0.25rem;
427+}
428+
429+.sv-kv-row {
430+ display: grid;
431+ grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr);
432+ gap: 0.25rem 1rem;
433+ padding: 0.5rem 0;
434+}
435+
436+.sv-kv-row + .sv-kv-row {
437+ border-top: 1px solid var(--sv-border, #2a313d);
438+}
439+
440+.sv-kv-row dt,
441+.sv-kv-row dd {
442+ margin: 0;
443+ min-width: 0;
444+ overflow-wrap: anywhere;
445+}
446+
447+.sv-kv-row dt {
448+ color: var(--sv-muted, #9aa3b2);
449+ font-weight: 600;
450+}
451+.sv-alert {
452+ border-radius: var(--sv-radius-card, 0.5rem);
453+ padding: 0.75rem 1rem;
454+ border: 1px solid var(--sv-border, #2a313d);
455+ transition: opacity 180ms ease, transform 180ms ease;
456+}
457+
458+.sv-alert-dismiss {
459+ opacity: 0;
460+ transform: translateY(-0.25rem);
461+}
462+
463+.sv-alert-success {
464+ background: color-mix(in srgb, var(--sv-success, #8ab4ff) 14%, var(--sv-surface, #171a21));
465+ border-color: color-mix(in srgb, var(--sv-success, #8ab4ff) 62%, var(--sv-border, #2a313d));
466+}
467+
468+.sv-alert-error {
469+ color: var(--sv-danger-text, #fecaca);
470+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
471+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 72%, var(--sv-border, #2a313d));
472+}
473+
474+.sv-toolbar,
475+.sv-button-row,
476+.sv-active-filters,
477+.sv-history-actions-panel,
478+.sv-history-row-actions {
479+ display: flex;
480+ flex-wrap: wrap;
481+ gap: 0.5rem;
482+ align-items: center;
483+}
484+
485+.sv-toolbar {
486+ align-items: end;
487+}
488+
489+.sv-history-actions-panel {
490+ margin-top: 0.75rem;
491+ justify-content: space-between;
492+}
493+
494+.sv-search-label {
495+ display: grid;
496+ gap: 0.25rem;
497+ min-width: min(24rem, 100%);
498+ color: var(--sv-muted, #9aa3b2);
499+ font-size: 12px;
500+ font-weight: 600;
501+}
502+
503+.sv-search-label input {
504+ border: 1px solid var(--sv-border, #2a313d);
505+ border-radius: var(--sv-radius-control, 0.35rem);
506+ padding: 0.55rem 0.65rem;
507+ background: var(--sv-surface-variant, #1d2129);
508+ color: var(--sv-text, #e8ebf0);
509+ font: inherit;
510+}
511+
512+.sv-button {
513+ box-sizing: border-box;
514+ display: inline-flex;
515+ align-items: center;
516+ justify-content: center;
517+ gap: 0.4rem;
518+ min-height: 2rem;
519+ border: 1px solid var(--sv-accent, #8ab4ff);
520+ border-radius: var(--sv-radius-control, 0.35rem);
521+ padding: 0.35rem 0.65rem;
522+ background: var(--sv-accent, #8ab4ff);
523+ color: var(--sv-background, #0f1115);
524+ cursor: pointer;
525+ font: inherit;
526+ font-weight: 600;
527+ text-decoration: none;
528+}
529+
530+.sv-button-secondary {
531+ border-color: var(--sv-border, #2a313d);
532+ background: var(--sv-surface-variant, #1d2129);
533+ color: var(--sv-text, #e8ebf0);
534+}
535+
536+.sv-button-danger {
537+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d));
538+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129));
539+ color: var(--sv-danger-text, #fecaca);
540+}
541+
542+.sv-button-danger:hover {
543+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 24%, var(--sv-surface-variant, #1d2129));
544+}
545+
546+.sv-button-danger:disabled {
547+ color: var(--sv-danger-text, #fecaca);
548+ opacity: 0.7;
549+}
550+
551+.sv-button-icon-only {
552+ width: 2rem;
553+ min-width: 2rem;
554+ min-height: 2rem;
555+ padding: 0.35rem;
556+}
557+
558+.sv-danger-link {
559+ border: 0;
560+ padding: 0.15rem 0.2rem;
561+ background: transparent;
562+ color: var(--sv-danger-text, #fecaca);
563+ cursor: pointer;
564+ font: inherit;
565+ font-weight: 650;
566+ text-decoration: none;
567+}
568+
569+.sv-danger-link:hover {
570+ color: var(--sv-danger, #ef4444);
571+ text-decoration: underline;
572+}
573+
574+.sv-button:focus-visible,
575+.sv-icon-button:focus-visible,
576+.sv-domain-filter:focus-visible,
577+.sv-danger-link:focus-visible,
578+.sv-filter-chip button:focus-visible,
579+.sv-link:focus-visible,
580+.sv-search-label input:focus-visible,
581+.sv-card-form input:focus-visible,
582+.sv-card-form select:focus-visible,
583+.sv-inline-form input:focus-visible,
584+.sv-inline-form select:focus-visible,
585+.sv-fieldset input:focus-visible,
586+.sv-fieldset select:focus-visible {
587+ outline: 2px solid var(--sv-accent, #8ab4ff);
588+ outline-offset: 2px;
589+}
590+
591+.sv-button:disabled {
592+ cursor: not-allowed;
593+ opacity: 0.45;
594+}
595+
596+.sv-filter-chip {
597+ display: inline-flex;
598+ gap: 0.35rem;
599+ align-items: center;
600+ border: 1px solid var(--sv-border, #2a313d);
601+ border-radius: var(--sv-radius-chip, 0.45rem);
602+ padding: 0.25rem 0.55rem;
603+ background: var(--sv-surface-variant, #1d2129);
604+}
605+
606+.sv-filter-chip button,
607+.sv-domain-filter,
608+.sv-domain-delete,
609+.sv-icon-button {
610+ border: 0;
611+ background: transparent;
612+ color: inherit;
613+ cursor: pointer;
614+ font: inherit;
615+}
616+
617+.sv-domain-filter {
618+ min-width: 0;
619+ overflow: hidden;
620+ color: var(--sv-text, #e8ebf0);
621+ font-weight: 700;
622+ padding: 0;
623+ text-align: left;
624+ text-overflow: ellipsis;
625+ white-space: nowrap;
626+}
627+
628+.sv-domain-delete {
629+ display: inline-grid;
630+ place-items: center;
631+ flex: 0 0 auto;
632+ width: 1rem;
633+ height: 1rem;
634+ padding: 0;
635+ border-radius: 999px;
636+ color: var(--sv-danger-text, #fecaca);
637+ font-size: 0.85rem;
638+ font-weight: 700;
639+ line-height: 1;
640+}
641+
642+.sv-domain-delete:hover {
643+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 18%, transparent);
644+}
645+
646+.sv-icon-button {
647+ display: inline-flex;
648+ align-items: center;
649+ justify-content: center;
650+ width: 1.75rem;
651+ height: 1.75rem;
652+ border-radius: 999px;
653+}
654+
655+.sv-icon {
656+ flex: 0 0 auto;
657+ width: 1rem;
658+ height: 1rem;
659+ stroke-width: 2.15;
660+}
661+
662+.sv-icon-button .sv-icon {
663+ width: 0.95rem;
664+ height: 0.95rem;
665+}
666+
667+.sv-sr-only {
668+ position: absolute;
669+ width: 1px;
670+ height: 1px;
671+ padding: 0;
672+ margin: -1px;
673+ overflow: hidden;
674+ -webkit-clip-path: inset(50%);
675+ clip-path: inset(50%);
676+ white-space: nowrap;
677+ border: 0;
678+}
679+
680+.sv-danger {
681+ color: var(--sv-danger-text, #fecaca);
682+}
683+
684+.sv-icon-button:hover,
685+.sv-filter-chip button:hover,
686+.sv-domain-filter:hover {
687+ background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent);
688+}
689+
690+.sv-confirm-backdrop {
691+ position: fixed;
692+ inset: 0;
693+ z-index: 1000;
694+ display: grid;
695+ place-items: center;
696+ padding: 1rem;
697+ background: rgba(0, 0, 0, 0.55);
698+ backdrop-filter: blur(2px);
699+}
700+
701+.sv-confirm-dialog {
702+ width: min(26rem, 100%);
703+ border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 48%, var(--sv-border, #2a313d));
704+ border-radius: var(--sv-radius-card, 0.5rem);
705+ padding: 1rem;
706+ background: var(--sv-surface, #171a21);
707+ color: var(--sv-text, #e8ebf0);
708+ box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35);
709+}
710+
711+.sv-confirm-dialog h3 {
712+ margin: 0;
713+ font-size: 1rem;
714+}
715+
716+.sv-confirm-dialog p {
717+ margin: 0.65rem 0 0;
718+ color: var(--sv-muted, #9aa3b2);
719+}
720+
721+.sv-confirm-actions {
722+ display: flex;
723+ justify-content: flex-end;
724+ gap: 0.5rem;
725+ margin-top: 1rem;
726+}
727+
728+.sv-history-item.sv-focused {
729+ outline: 2px solid var(--sv-accent, #8ab4ff);
730+ outline-offset: 0.35rem;
731+ border-radius: var(--sv-radius-control, 0.35rem);
732+}
733+
734+.sv-key-hints kbd {
735+ border: 1px solid var(--sv-border, #2a313d);
736+ border-radius: 0.25rem;
737+ padding: 0.1rem 0.3rem;
738+ background: var(--sv-surface-variant, #1d2129);
739+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
740+}
741+
742+.sv-card-form,
743+.sv-inline-form {
744+ display: flex;
745+ flex-wrap: wrap;
746+ gap: 0.65rem;
747+ align-items: end;
748+ margin-top: 0.75rem;
749+}
750+
751+.sv-card-form {
752+ padding: 0.75rem;
753+ border: 1px solid var(--sv-border, #2a313d);
754+ border-radius: var(--sv-radius-card, 0.5rem);
755+ background: var(--sv-surface-variant, #1d2129);
756+}
757+
758+.sv-card-form label,
759+.sv-inline-form label,
760+.sv-fieldset label {
761+ display: grid;
762+ gap: 0.25rem;
763+ min-width: min(14rem, 100%);
764+ color: var(--sv-muted, #9aa3b2);
765+ font-size: 12px;
766+ font-weight: 600;
767+}
768+
769+.sv-card-form input,
770+.sv-card-form select,
771+.sv-inline-form input,
772+.sv-inline-form select,
773+.sv-fieldset input,
774+.sv-fieldset select {
775+ border: 1px solid var(--sv-border, #2a313d);
776+ border-radius: var(--sv-radius-control, 0.35rem);
777+ padding: 0.5rem 0.6rem;
778+ background: var(--sv-surface-variant, #1d2129);
779+ color: var(--sv-text, #e8ebf0);
780+ font: inherit;
781+}
782+
783+.sv-card-form input[type="color"],
784+.sv-inline-form input[type="color"],
785+.sv-fieldset input[type="color"] {
786+ width: 4rem;
787+ min-width: 4rem;
788+ height: 2.1rem;
789+ padding: 0.15rem;
790+ background: transparent;
791+ cursor: pointer;
792+}
793+
794+.sv-card-form input[type="color"]::-webkit-color-swatch-wrapper,
795+.sv-inline-form input[type="color"]::-webkit-color-swatch-wrapper,
796+.sv-fieldset input[type="color"]::-webkit-color-swatch-wrapper {
797+ padding: 0;
798+}
799+
800+.sv-card-form input[type="color"]::-webkit-color-swatch,
801+.sv-inline-form input[type="color"]::-webkit-color-swatch,
802+.sv-fieldset input[type="color"]::-webkit-color-swatch {
803+ border: 0;
804+ border-radius: calc(var(--sv-radius-control, 0.35rem) - 0.12rem);
805+}
806+
807+.sv-config-form,
808+.sv-fieldset {
809+ display: flex;
810+ flex-wrap: wrap;
811+ gap: 0.65rem;
812+ align-items: end;
813+}
814+
815+.sv-fieldset {
816+ width: 100%;
817+ margin: 0;
818+ padding: 0.75rem;
819+ border: 1px solid var(--sv-border, #2a313d);
820+ border-radius: var(--sv-radius-card, 0.5rem);
821+}
822+
823+.sv-fieldset legend {
824+ color: var(--sv-muted, #9aa3b2);
825+ font-weight: 700;
826+ padding: 0 0.35rem;
827+}
828+
829+.sv-keybinding-row {
830+ display: grid;
831+ grid-template-columns: minmax(0, 1fr) minmax(18rem, auto);
832+ gap: 1rem;
833+ align-items: start;
834+}
835+
836+.sv-filter-panel {
837+ display: grid;
838+ gap: 0.6rem;
839+ margin-top: 0.75rem;
840+}
841+
842+.sv-history-row-actions .sv-button {
843+ min-width: 4.75rem;
844+}
845+
846+.sv-history-row-actions .sv-button-icon-only {
847+ width: 2rem;
848+ min-width: 2rem;
849+ flex: 0 0 2rem;
850+}
851+
852+.sv-button-active {
853+ border-color: var(--sv-accent, #8ab4ff);
854+ box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;
855+}
856+
857+.sv-tag-picker {
858+ display: grid;
859+ gap: 0.45rem;
860+ min-width: min(22rem, 100%);
861+ margin: 0;
862+ padding: 0;
863+ border: 0;
864+}
865+
866+.sv-tag-picker legend {
867+ color: var(--sv-muted, #9aa3b2);
868+ font-size: 12px;
869+ font-weight: 600;
870+}
871+
872+.sv-card-form .sv-tag-toggle,
873+.sv-tag-toggle {
874+ display: inline-flex;
875+ min-width: 0;
876+ color: inherit;
877+ font-size: inherit;
878+ font-weight: inherit;
879+ cursor: pointer;
880+}
881+
882+.sv-tag-toggle input {
883+ position: absolute;
884+ inline-size: 1px;
885+ block-size: 1px;
886+ opacity: 0;
887+ pointer-events: none;
888+}
889+
890+.sv-tag-toggle span,
891+.sv-tag-button {
892+ display: inline-flex;
893+ align-items: center;
894+ gap: 0.35rem;
895+}
896+
897+.sv-tag-toggle > span {
898+ min-height: 1.45rem;
899+ border: 1px solid var(--sv-border, #2a313d);
900+ border-radius: var(--sv-radius-chip, 0.45rem);
901+ padding: 0.16rem 0.45rem;
902+ background: var(--sv-surface-variant, #1d2129);
903+ color: var(--sv-text, #e8ebf0);
904+ font-size: 12px;
905+ font-weight: 700;
906+}
907+
908+.sv-tag-toggle input:checked + span,
909+.sv-tag-button-active {
910+ border-color: var(--sv-accent, #8ab4ff);
911+ background: var(--sv-accent, #8ab4ff);
912+ color: var(--sv-background, #0f1115);
913+}
914+
915+.sv-favorite-item {
916+ display: grid;
917+ gap: 0.75rem;
918+}
919+
920+.sv-favorite-header {
921+ display: flex;
922+ justify-content: space-between;
923+ gap: 1rem;
924+ align-items: start;
925+}
926+
927+.sv-tag-controls {
928+ display: flex;
929+ flex-wrap: wrap;
930+ gap: 0.45rem;
931+ align-items: center;
932+}
933+
934+.sv-tag-dot {
935+ display: inline-block;
936+ width: 0.65rem;
937+ height: 0.65rem;
938+ border-radius: 999px;
939+ margin-right: 0.35rem;
940+ border: 1px solid color-mix(in srgb, var(--sv-border, #2a313d) 65%, transparent);
941+}
942+
943+.sv-favorite-item.sv-focused {
944+ outline: 2px solid var(--sv-accent, #8ab4ff);
945+ outline-offset: 0.35rem;
946+ border-radius: var(--sv-radius-control, 0.35rem);
947+}
948+
949+@media (max-width: 720px) {
950+ .sv-favorite-header,
951+ .sv-keybinding-row,
952+ .sv-history-item,
953+ .sv-history-actions-panel,
954+ .sv-toolbar {
955+ display: grid;
956+ grid-template-columns: 1fr;
957+ }
958+
959+ .sv-kv-row {
960+ grid-template-columns: 1fr;
961+ }
962+
963+ .sv-card-form,
964+ .sv-inline-form,
965+ .sv-fieldset,
966+ .sv-button-row {
967+ align-items: stretch;
968+ }
969+
970+ .sv-card-form label,
971+ .sv-inline-form label,
972+ .sv-fieldset label,
973+ .sv-button,
974+ .sv-button-secondary {
975+ width: 100%;
976+ }
977+}
M assets/webui_embed.go +5 -3
@@ -4,10 +4,12 @@ import (
4 "embed"4 "embed"
5 )5 )
6 6
7-// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).7+// WebUIAssets contains the embedded systemviews build output.
8-// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.8+// The legacy WebUIAssets name is kept for compatibility with existing callers.
9+// Used by the scheme handlers to serve dumb://error, dumb://config,
10+// dumb://history, and dumb://favorites.
9 //11 //
10-//go:embed webui/*12+//go:embed systemviews/*
11 var WebUIAssets embed.FS13 var WebUIAssets embed.FS
12 14
13 // LogoSVG contains the dumber logo as SVG for desktop icon installation.15 // LogoSVG contains the dumber logo as SVG for desktop icon installation.
M aur/dumber-browser-git/.SRCINFO +0 -1
@@ -6,7 +6,6 @@ pkgbase = dumber-browser-git
6 arch = x86_646 arch = x86_64
7 license = MIT7 license = MIT
8 makedepends = go>=1.258 makedepends = go>=1.25
9- makedepends = npm
10 makedepends = git9 makedepends = git
11 depends = gtk410 depends = gtk4
12 depends = webkitgtk-6.011 depends = webkitgtk-6.0
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9-makedepends=('go>=1.25' 'npm' 'git')9+makedepends=('go>=1.26' 'git' 'brotli')
10 depends=('gtk4' 'webkitgtk-6.0')10 depends=('gtk4' 'webkitgtk-6.0')
11 optdepends=(11 optdepends=(
12 'gst-plugins-base: Base media codecs'12 'gst-plugins-base: Base media codecs'
A cmd/systemviews/bootstrap.go +64 -0
@@ -0,0 +1,64 @@
1+package main
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/ui/systemviews"
9+)
10+
11+type bridgeServices interface {
12+ port.SystemviewHistoryService
13+ port.SystemviewFavoritesService
14+ port.SystemviewConfigService
15+}
16+
17+func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {
18+ route := systemviews.ParseRoute(locationURI)
19+ return systemviews.NewApp(systemviews.Dependencies{
20+ DOM: dom,
21+ History: bridge,
22+ Favorites: bridge,
23+ Config: bridgeConfigProxy{bridge: bridge, route: route},
24+ LocationURI: locationURI,
25+ })
26+}
27+
28+type bridgeConfigProxy struct {
29+ bridge bridgeServices
30+ route systemviews.Route
31+}
32+
33+func (p bridgeConfigProxy) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
34+ return p.bridge.Current(ctx)
35+}
36+
37+func (p bridgeConfigProxy) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
38+ return p.bridge.Default(ctx)
39+}
40+
41+func (p bridgeConfigProxy) Save(ctx context.Context, cfg dto.WebUIConfig) error {
42+ return p.bridge.Save(ctx, cfg)
43+}
44+
45+func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
46+ // Keybindings are only rendered by dumb://config; skip the bridge load for
47+ // other systemview routes to keep initial history/favorites loads cheap.
48+ if p.route != systemviews.RouteConfig {
49+ return port.KeybindingsConfig{}, nil
50+ }
51+ return p.bridge.GetKeybindings(ctx)
52+}
53+
54+func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
55+ return p.bridge.SetKeybinding(ctx, req)
56+}
57+
58+func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
59+ return p.bridge.ResetKeybinding(ctx, req)
60+}
61+
62+func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error {
63+ return p.bridge.ResetAllKeybindings(ctx)
64+}
A cmd/systemviews/bootstrap_test.go +258 -0
@@ -0,0 +1,258 @@
1+package main
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestNewBridgeApp_WiresHistoryService(t *testing.T) {
18+ t.Parallel()
19+
20+ bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}}
21+ app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)
22+
23+ require.NoError(t, app.LoadInitial(context.Background()))
24+ assert.True(t, bridge.calledHistory.Load())
25+ assert.False(t, bridge.calledFavorites.Load())
26+ assert.True(t, bridge.calledConfig.Load())
27+ assert.False(t, bridge.calledKeybindings.Load())
28+}
29+
30+func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
31+ t.Parallel()
32+
33+ bridge := &fakeBridgeService{
34+ historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},
35+ currentConfig: dto.SystemviewConfigPayload{
36+ Appearance: dto.WebUIAppearanceConfig{
37+ ColorScheme: "prefer-dark",
38+ DarkPalette: dto.ColorPalette{
39+ Background: "#111111",
40+ Surface: "#1a1a1a",
41+ SurfaceVariant: "#2a2a2a",
42+ Text: "#f5f5f5",
43+ Muted: "#a0a0a0",
44+ Accent: "#66aaff",
45+ Border: "#333333",
46+ },
47+ },
48+ },
49+ }
50+ dom := &fakeDOM{mounts: make(chan string, 4)}
51+ app := newBridgeApp(dom, "dumb://history", bridge)
52+
53+ require.NoError(t, app.Run())
54+ require.Eventually(t, bridge.calledHistory.Load, time.Second, 10*time.Millisecond)
55+ assert.True(t, bridge.calledConfig.Load())
56+ assert.False(t, bridge.calledKeybindings.Load())
57+ html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)
58+ assert.Contains(t, html, `class="sv-app sv-dark"`)
59+ assert.Contains(t, html, `--sv-background: #111111;`)
60+}
61+
62+func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
63+ t.Parallel()
64+
65+ bridge := &fakeBridgeService{
66+ favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},
67+ folders: []*entity.Folder{{Name: "Read Later"}},
68+ tags: []*entity.Tag{{Name: "Go"}},
69+ }
70+ app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)
71+
72+ require.NoError(t, app.LoadInitial(context.Background()))
73+ assert.False(t, bridge.calledHistory.Load())
74+ assert.True(t, bridge.calledFavorites.Load())
75+ assert.True(t, bridge.calledConfig.Load())
76+ assert.False(t, bridge.calledKeybindings.Load())
77+}
78+
79+func TestNewBridgeApp_WiresConfigService(t *testing.T) {
80+ t.Parallel()
81+
82+ bridge := &fakeBridgeService{
83+ currentConfig: dto.SystemviewConfigPayload{EngineType: "webkit"},
84+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},
85+ }
86+ app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
87+
88+ require.NoError(t, app.LoadInitial(context.Background()))
89+ assert.False(t, bridge.calledHistory.Load())
90+ assert.False(t, bridge.calledFavorites.Load())
91+ assert.True(t, bridge.calledConfig.Load())
92+ assert.True(t, bridge.calledKeybindings.Load())
93+}
94+
95+type fakeDOM struct {
96+ html string
97+ mounts chan string
98+}
99+
100+func (f *fakeDOM) Mount(html string) error {
101+ f.html = html
102+ if f.mounts != nil {
103+ select {
104+ case f.mounts <- html:
105+ default:
106+ }
107+ }
108+ return nil
109+}
110+
111+func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string {
112+ t.Helper()
113+ deadline := time.After(time.Second)
114+ for {
115+ select {
116+ case html := <-mounts:
117+ matched := true
118+ for _, value := range values {
119+ if !strings.Contains(html, value) {
120+ matched = false
121+ break
122+ }
123+ }
124+ if matched {
125+ return html
126+ }
127+ case <-deadline:
128+ t.Fatalf("timed out waiting for DOM mount containing %q", values)
129+ return ""
130+ }
131+ }
132+}
133+
134+// Handwritten fake intentionally tracks state across history, favorites, config,
135+// and keybindings boundaries for the composite bootstrap assertions.
136+type fakeBridgeService struct {
137+ calledHistory atomic.Bool
138+ calledFavorites atomic.Bool
139+ calledConfig atomic.Bool
140+ calledKeybindings atomic.Bool
141+
142+ historyEntries []*entity.HistoryEntry
143+ favorites []*entity.Favorite
144+ folders []*entity.Folder
145+ tags []*entity.Tag
146+ currentConfig dto.SystemviewConfigPayload
147+ keybindings port.KeybindingsConfig
148+}
149+
150+func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
151+ f.calledHistory.Store(true)
152+ return f.historyEntries, nil
153+}
154+
155+func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {
156+ f.calledHistory.Store(true)
157+ return f.historyEntries, nil
158+}
159+
160+func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {
161+ f.calledHistory.Store(true)
162+ return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
163+}
164+
165+func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
166+ return nil, nil
167+}
168+
169+func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil }
170+
171+func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil }
172+
173+func (*fakeBridgeService) Stats(context.Context) (*entity.HistoryStats, error) {
174+ return nil, nil
175+}
176+
177+func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
178+ return nil, nil
179+}
180+
181+func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
182+ return nil, nil
183+}
184+
185+func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }
186+
187+func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
188+ f.calledFavorites.Store(true)
189+ return f.favorites, nil
190+}
191+
192+func (*fakeBridgeService) CreateFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
193+ return nil, nil
194+}
195+
196+func (*fakeBridgeService) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
197+ return nil, nil
198+}
199+
200+func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil }
201+
202+func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {
203+ return f.folders, nil
204+}
205+
206+func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) {
207+ return f.tags, nil
208+}
209+
210+func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil }
211+
212+func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }
213+
214+func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) {
215+ return nil, nil
216+}
217+
218+func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil }
219+
220+func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil }
221+
222+func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) {
223+ return nil, nil
224+}
225+
226+func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil }
227+
228+func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil }
229+
230+func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil }
231+
232+func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
233+
234+func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
235+ f.calledConfig.Store(true)
236+ return f.currentConfig, nil
237+}
238+
239+func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
240+ return dto.SystemviewConfigPayload{}, nil
241+}
242+
243+func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }
244+
245+func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
246+ f.calledKeybindings.Store(true)
247+ return f.keybindings, nil
248+}
249+
250+func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
251+ return port.SetKeybindingResponse{}, nil
252+}
253+
254+func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
255+ return nil
256+}
257+
258+func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil }
A cmd/systemviews/main.go +30 -0
@@ -0,0 +1,30 @@
1+//go:build js && wasm
2+
3+package main
4+
5+import (
6+ "syscall/js"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"
9+ "github.com/bnema/dumber/internal/ui/systemviews"
10+)
11+
12+func main() {
13+ bridge := systemviewsbridge.NewBrowserClient()
14+ href := ""
15+ location := js.Global().Get("location")
16+ if location.Truthy() {
17+ hrefValue := location.Get("href")
18+ if hrefValue.Truthy() && hrefValue.Type() == js.TypeString {
19+ href = hrefValue.String()
20+ }
21+ }
22+ app := newBridgeApp(systemviews.NewDOM(), href, bridge)
23+
24+ if err := app.Run(); err != nil {
25+ js.Global().Get("console").Call("error", err.Error())
26+ panic(err)
27+ }
28+
29+ select {}
30+}
A cmd/systemviews/main_stub.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package main
4+
5+func main() {}
M docs/config/index.md +1 -1
@@ -204,7 +204,7 @@ border = "#363636"
204 **Example:**204 **Example:**
205 ```toml205 ```toml
206 [workspace]206 [workspace]
207-new_pane_url = "dumb://home"207+new_pane_url = "dumb://history"
208 ```208 ```
209 209
210 ### Pane Mode210 ### Pane Mode
M docs/reference/keybindings.md +15 -2
@@ -80,6 +80,9 @@ These work outside modal modes:
80 | Action | Keys |80 | Action | Keys |
81 |--------|------|81 |--------|------|
82 | Toggle floating pane | `Alt+F` |82 | Toggle floating pane | `Alt+F` |
83+| Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` |
84+| Toggle Favorites system view in right split | unbound by default |
85+| Toggle Config system view in right split | unbound by default |
83 | Close pane (or release floating pane) | `Ctrl+W` |86 | Close pane (or release floating pane) | `Ctrl+W` |
84 | Next tab | `Ctrl+Tab` |87 | Next tab | `Ctrl+Tab` |
85 | Previous tab | `Ctrl+Shift+Tab` |88 | Previous tab | `Ctrl+Shift+Tab` |
@@ -90,6 +93,7 @@ These work outside modal modes:
90 93
91 - `Alt+F` is the only floating-pane shortcut enabled by default.94 - `Alt+F` is the only floating-pane shortcut enabled by default.
92 - `Alt+F` toggles floating visibility and keeps floating pane state intact.95 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
96+- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary.
93 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.97 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
94 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.98 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
95 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).99 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
@@ -108,12 +112,21 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
108 split-right = ["arrowright", "r"]112 split-right = ["arrowright", "r"]
109 close-pane = ["x", "q"]113 close-pane = ["x", "q"]
110 114
111-[workspace.shortcuts.actions.close_pane]115+[workspace.shortcuts.actions.close-pane]
112 keys = ["ctrl+w"]116 keys = ["ctrl+w"]
113 117
114-[workspace.shortcuts.actions.toggle_floating_pane]118+[workspace.shortcuts.actions.toggle-floating-pane]
115 keys = ["alt+f"]119 keys = ["alt+f"]
116 120
121+[workspace.shortcuts.actions.toggle-history-systemview]
122+keys = ["ctrl+h"]
123+
124+[workspace.shortcuts.actions.toggle-favorites-systemview]
125+keys = []
126+
127+[workspace.shortcuts.actions.toggle-config-systemview]
128+keys = []
129+
117 [workspace.floating_pane]130 [workspace.floating_pane]
118 width_pct = 0.82131 width_pct = 0.82
119 height_pct = 0.72132 height_pct = 0.72
A docs/systemviews-wasm-crud-plan.md +115 -0
@@ -0,0 +1,115 @@
1+# Systemviews WASM CRUD and Architecture Plan
2+
3+This note captures the parity target for the `feat/systemviews-wasm` branch after merging `main`.
4+
5+## Current branch baseline
6+
7+- Worktree: `.worktrees/feat/systemviews-wasm`
8+- Branch: `feat/systemviews-wasm`
9+- Merge baseline: `d84415e5` (`Merge branch 'main' into feat/systemviews-wasm`)
10+- Vendor baseline: `go mod vendor` has been run in the worktree and `go list -mod=vendor ./...` succeeds there.
11+- Root-checkout vendoring diagnostics were corrected separately with `go mod vendor`; branch validation should still use `-mod=vendor` after dependency changes.
12+
13+## Clean architecture contract
14+
15+Keep the dependency direction explicit:
16+
17+| Layer | Role for systemviews | Must not do |
18+| --- | --- | --- |
19+| `internal/domain` | Pure entities/value objects and pure helpers such as history range parsing. | Import ports, usecases, adapters, GTK/CEF/WebKit, or config packages. |
20+| `internal/application/usecase` | Own favorites/history/config/keybinding behavior and validation. | Import infrastructure adapters or UI packages. |
21+| `internal/application/port` | Define the systemview-facing contracts and DTOs. | Depend on CEF/WebKit/SQLite/config concrete types. |
22+| `internal/infrastructure/*` | Implement handlers, scheme serving, bridge transports, persistence, config payload readers. | Put UI workflow or business rules here. |
23+| `internal/ui/systemviews` | Render and orchestrate WASM UI through ports. | Call repositories, config package, CEF/WebKit, or SQLite directly. |
24+| `internal/ui/dispatcher` / coordinators | Convert native actions into pane/tab/navigation orchestration. | Bypass usecases for domain state changes. |
25+
26+Preferred direction for new work:
27+
28+1. Add behavior to existing usecases first when it is business logic.
29+2. Expose it through application ports.
30+3. Implement WebUI/systemview handlers in `internal/infrastructure/handlers`.
31+4. Consume those handlers from `internal/infrastructure/systemviewsbridge`.
32+5. Render and bind user interactions in `internal/ui/systemviews`.
33+
34+## Old Svelte UI CRUD matrix
35+
36+### History (`webui/src/pages/homepage/*`)
37+
38+| Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
39+| --- | --- | --- | --- | --- |
40+| Read timeline | `HistoryPanel.svelte`, `HistoryTimeline.svelte`, `HistoryItem.svelte` | `history_timeline` | Present, but bare list only (`internal/ui/systemviews/history_view.go`). | Add pagination, loading/error/empty states, row metadata, favicons, visit counts. |
41+| Read/search | `HistorySearch.svelte`, `keyboard.ts` local fuzzy + FTS | `history_search_fts` | Bridge client supports it. | Add search UI, debounce/local filtering, FTS fallback, visible result count. |
42+| Read filters/stats | `HistoryFilters.svelte`, `AnalyticsPanel.svelte` | `history_domain_stats`, `history_analytics` | Bridge client supports both; config route renders keybinding/config summaries only. | Add domain chips, stats summary, analytics cards/charts or text equivalent. |
43+| Create | Browser navigation writes history outside the systemview UI. | Existing history repository/usecases. | Not a user-facing CRUD action. | No manual create needed. |
44+| Update | Not supported in old UI. | None. | None. | Keep out of scope unless a future title-edit requirement appears. |
45+| Delete one | `HistoryItem.svelte`, `keyboard.ts` | `history_delete_entry` | Bridge client supports it; UI not interactive. | Add row delete action with confirmation and refresh. |
46+| Delete range/all | `HistoryCleanup.svelte`, `keyboard.ts` | `history_delete_range`, `history_clear_all` | Bridge client supports range; route UI not interactive. | Add cleanup modal/actions for hour/day/week/month/all. |
47+| Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. |
48+| Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. |
49+
50+### Favorites, folders, tags (`webui/src/pages/homepage/favorites/*`)
51+
52+| Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
53+| --- | --- | --- | --- | --- |
54+| Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`). | Add rich rows/cards, favicons, shortcut badges, folder/tag metadata. |
55+| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. |
56+| Update favorite | `FavoriteEditor.svelte` locally updates title and uses folder/shortcut/tag handlers. | `favorite_set_shortcut`, `favorite_set_folder`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep folder/shortcut/tag specialized calls or fold into update after design. |
57+| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. |
58+| Read folders | `FolderList.svelte` | `folder_list` | Present as static list. | Add counts, selected filter, empty state. |
59+| Create folder | `FolderList.svelte` inline create | `folder_create` | Bridge and handler exist. | Add interactive UI. |
60+| Update folder | Handler exists (`folder_update`). | `folder_update` | Bridge exists. | Add rename/icon UI. |
61+| Delete folder | Handler exists (`folder_delete`). | `folder_delete` | Bridge exists. | Add UI, confirm, refresh favorite folder state. |
62+| Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |
63+| Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |
64+| Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |
65+| Delete tag | Handler exists (`tag_delete`). | `tag_delete` | Bridge exists. | Add confirm UI and refresh assignments. |
66+| Assign/remove tag | `FavoriteEditor.svelte` | `tag_assign`, `tag_remove` | Bridge exists. | Add editor UI. |
67+
68+### Config and keybindings (`webui/src/pages/ConfigPage.svelte`, `webui/src/pages/config/*`)
69+
70+| Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
71+| --- | --- | --- | --- | --- |
72+| Read config | `ConfigPage.svelte` | `/api/config` | Bridge supports direct API and route renders flattened values. | Replace flattened read-only view with grouped editable forms. |
73+| Read defaults | Reset dialog in `ConfigPage.svelte` | `/api/config/default` | Bridge supports direct API. | Add reset-to-default flow. |
74+| Save config | `save_config` via native bridge | `save_config` | Bridge supports it. Existing app config watcher hot-reloads appearance/keybindings/engine settings. | Add form save flow, validation, success/error UI, refresh after save. |
75+| Appearance update | Fonts, UI scale, color scheme, palettes | `save_config` | Payload shape exists in `SystemviewConfigPayload`. | Add form controls and palette validation. |
76+| Search shortcuts CRUD | `ShortcutsTable.svelte` | Saved inside config payload | Missing interactive UI. | Add table/list editing and `%s`/`{query}` validation. |
77+| Performance update | Performance tab | `save_config` | Payload shape exists; CEF gating needed. | Add profile/custom fields and restart warning. |
78+| Keybindings read | `KeybindingsTab.svelte` | `get_keybindings` | Route renders read-only keybinding groups. | Add editable keybinding UI. |
79+| Keybindings update | `KeyCaptureModal.svelte` | `set_keybinding`, `reset_keybinding`, `reset_all_keybindings` | Bridge supports message types. | Add capture/reset UI and conflict display. |
80+
81+## Native shortcut and split-pane target
82+
83+Add first-class global actions:
84+
85+| Action | Default key | Target behavior |
86+| --- | --- | --- |
87+| `toggle_history_systemview` | `ctrl+h` | Split active pane to the right with `dumb://history`, focus an existing history systemview pane, or close it if already active. |
88+| `toggle_favorites_systemview` | Unset by default. | Split/focus/toggle `dumb://favorites`. |
89+| `toggle_config_systemview` | Unset by default; user can bind in config. | Split/focus/toggle `dumb://config`. |
90+
91+Implementation path:
92+
93+1. Add input actions in `internal/ui/input/shortcuts.go` and config defaults/schema in `internal/infrastructure/config`.
94+2. Map the actions in global shortcuts and keybinding DTOs.
95+3. Add a coordinator method such as `WorkspaceCoordinator.SplitURL(ctx, SplitRight, "dumb://history")` so dispatcher does not manipulate pane internals.
96+4. Use existing `ManagePanesUseCase.Split` with `SplitPaneInput.InitialURL` to keep domain/usecase clean.
97+5. Add tests in input, dispatcher, config defaults/schema, and workspace coordinator.
98+
99+## Completed branch notes
100+
101+- Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates.
102+- `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime.
103+- History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation.
104+- Favorites supports create/update/delete for favorites, folders, and tags; folder/tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation.
105+- Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save.
106+- Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path.
107+- Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states.
108+
109+## Implementation notes
110+
111+- Do not resurrect `webui/`; the branch intentionally removed Svelte assets.
112+- Keep WASM UI code small and stateful. Avoid pushing business rules into HTML string helpers.
113+- Prefer explicit bridge request methods over ad-hoc message strings in UI code.
114+- Every destructive action needs confirmation and a visible refresh/error path.
115+- Every feature should remain usable by keyboard because these pages are browser management surfaces, not marketing pages.
M go.mod +13 -1
@@ -3,8 +3,9 @@ module github.com/bnema/dumber
3 go 1.26.13 go 1.26.1
4 4
5 require (5 require (
6+ github.com/a-h/templ v0.3.1001
6 github.com/bnema/purego v0.11.0-bnema.27 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.10.28+ github.com/bnema/purego-cef v0.11.0
8 github.com/bnema/purego-ffmpeg v0.3.19 github.com/bnema/purego-ffmpeg v0.3.1
9 github.com/bnema/purego-pipewire v0.1.210 github.com/bnema/purego-pipewire v0.1.2
10 github.com/bnema/purego-sqlite v0.1.211 github.com/bnema/purego-sqlite v0.1.2
@@ -27,18 +28,23 @@ require (
27 )28 )
28 29
29 require (30 require (
31+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
32+ github.com/andybalholm/brotli v1.2.0 // indirect
30 github.com/atotto/clipboard v0.1.4 // indirect33 github.com/atotto/clipboard v0.1.4 // indirect
31 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect34 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
35+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
32 github.com/charmbracelet/colorprofile v0.4.1 // indirect36 github.com/charmbracelet/colorprofile v0.4.1 // indirect
33 github.com/charmbracelet/x/ansi v0.11.6 // indirect37 github.com/charmbracelet/x/ansi v0.11.6 // indirect
34 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect38 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
35 github.com/charmbracelet/x/term v0.2.2 // indirect39 github.com/charmbracelet/x/term v0.2.2 // indirect
40+ github.com/cli/browser v1.3.0 // indirect
36 github.com/clipperhouse/displaywidth v0.9.0 // indirect41 github.com/clipperhouse/displaywidth v0.9.0 // indirect
37 github.com/clipperhouse/stringish v0.1.1 // indirect42 github.com/clipperhouse/stringish v0.1.1 // indirect
38 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect43 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
39 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect44 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
40 github.com/davecgh/go-spew v1.1.1 // indirect45 github.com/davecgh/go-spew v1.1.1 // indirect
41 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect46 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
47+ github.com/fatih/color v1.16.0 // indirect
42 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect48 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
43 github.com/inconshreveable/mousetrap v1.1.0 // indirect49 github.com/inconshreveable/mousetrap v1.1.0 // indirect
44 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect50 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -50,6 +56,7 @@ require (
50 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect56 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
51 github.com/muesli/cancelreader v0.2.2 // indirect57 github.com/muesli/cancelreader v0.2.2 // indirect
52 github.com/muesli/termenv v0.16.0 // indirect58 github.com/muesli/termenv v0.16.0 // indirect
59+ github.com/natefinch/atomic v1.0.1 // indirect
53 github.com/pmezard/go-difflib v1.0.0 // indirect60 github.com/pmezard/go-difflib v1.0.0 // indirect
54 github.com/rivo/uniseg v0.4.7 // indirect61 github.com/rivo/uniseg v0.4.7 // indirect
55 github.com/russross/blackfriday/v2 v2.1.0 // indirect62 github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -64,8 +71,13 @@ require (
64 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect71 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
65 go.uber.org/multierr v1.11.0 // indirect72 go.uber.org/multierr v1.11.0 // indirect
66 go.yaml.in/yaml/v3 v3.0.4 // indirect73 go.yaml.in/yaml/v3 v3.0.4 // indirect
74+ golang.org/x/mod v0.34.0 // indirect
75+ golang.org/x/net v0.52.0 // indirect
67 golang.org/x/sync v0.20.0 // indirect76 golang.org/x/sync v0.20.0 // indirect
68 golang.org/x/text v0.36.0 // indirect77 golang.org/x/text v0.36.0 // indirect
78+ golang.org/x/tools v0.43.0 // indirect
69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect79 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
70 modernc.org/sqlite v1.47.0 // indirect80 modernc.org/sqlite v1.47.0 // indirect
71 )81 )
82+
83+tool github.com/a-h/templ/cmd/templ
M go.sum +24 -0 generated file: collapsed
A internal/application/dto/favorites.go +21 -0
@@ -0,0 +1,21 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// FavoriteCreateInput contains fields needed to create a favorite from a UI port.
6+type FavoriteCreateInput struct {
7+ URL string `json:"url"`
8+ Title string `json:"title"`
9+ FaviconURL string `json:"favicon_url"`
10+ FolderID *entity.FolderID `json:"folder_id"`
11+ Tags []entity.TagID `json:"tags"`
12+}
13+
14+// FavoriteUpdateInput contains editable favorite metadata.
15+type FavoriteUpdateInput struct {
16+ ID entity.FavoriteID `json:"id"`
17+ Title string `json:"title"`
18+ FaviconURL string `json:"favicon_url"`
19+ FolderID *entity.FolderID `json:"folder_id"`
20+ ShortcutKey *int `json:"shortcut_key"`
21+}
A internal/application/dto/history.go +14 -0
@@ -0,0 +1,14 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// HistorySearchInput holds search parameters for history search use cases.
6+type HistorySearchInput struct {
7+ Query string
8+ Limit int
9+}
10+
11+// HistorySearchOutput holds search results for history search use cases.
12+type HistorySearchOutput struct {
13+ Matches []entity.HistoryMatch
14+}
A internal/application/dto/webui_config.go +92 -0
@@ -0,0 +1,92 @@
1+package dto
2+
3+// WebUIConfig represents the subset of configuration editable in dumb://config.
4+type WebUIConfig struct {
5+ Appearance WebUIAppearanceConfig `json:"appearance"`
6+ Performance WebUIPerformanceConfig `json:"performance"`
7+ DefaultUIScale float64 `json:"default_ui_scale"`
8+ DefaultSearchEngine string `json:"default_search_engine"`
9+ SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
10+}
11+
12+// SystemviewCustomPerformancePayload is the editable custom performance profile shape.
13+type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig
14+
15+// SystemviewResolvedPerformancePayload is the resolved effective performance shape.
16+type SystemviewResolvedPerformancePayload struct {
17+ SkiaCPUThreads int `json:"skia_cpu_threads"`
18+ SkiaGPUThreads int `json:"skia_gpu_threads"`
19+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
20+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
21+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
22+ ConservativeThreshold float64 `json:"conservative_threshold"`
23+ StrictThreshold float64 `json:"strict_threshold"`
24+}
25+
26+// SystemviewHardwarePayload exposes detected hardware to the settings UI.
27+type SystemviewHardwarePayload struct {
28+ CPUCores int `json:"cpu_cores"`
29+ CPUThreads int `json:"cpu_threads"`
30+ TotalRAMMB int `json:"total_ram_mb"`
31+ GPUVendor string `json:"gpu_vendor"`
32+ GPUName string `json:"gpu_name"`
33+ VRAMMB int `json:"vram_mb"`
34+}
35+
36+// SystemviewPerformancePayload is the performance tab payload expected by the UI.
37+type SystemviewPerformancePayload struct {
38+ Profile string `json:"profile"`
39+ Custom SystemviewCustomPerformancePayload `json:"custom"`
40+ Resolved SystemviewResolvedPerformancePayload `json:"resolved"`
41+ Hardware SystemviewHardwarePayload `json:"hardware"`
42+}
43+
44+// SystemviewConfigPayload is the full JSON shape expected by dumb://config.
45+type SystemviewConfigPayload struct {
46+ Appearance WebUIAppearanceConfig `json:"appearance"`
47+ Performance SystemviewPerformancePayload `json:"performance"`
48+ DefaultUIScale float64 `json:"default_ui_scale"`
49+ DefaultSearchEngine string `json:"default_search_engine"`
50+ SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
51+ EngineType string `json:"engine_type"`
52+}
53+
54+// WebUIPerformanceConfig represents performance settings editable in dumb://config.
55+type WebUIPerformanceConfig struct {
56+ Profile string `json:"profile"`
57+ Custom WebUICustomPerformanceConfig `json:"custom"`
58+}
59+
60+// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
61+type WebUICustomPerformanceConfig struct {
62+ SkiaCPUThreads int `json:"skia_cpu_threads"`
63+ SkiaGPUThreads int `json:"skia_gpu_threads"`
64+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
65+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
66+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
67+}
68+
69+type WebUIAppearanceConfig struct {
70+ SansFont string `json:"sans_font"`
71+ SerifFont string `json:"serif_font"`
72+ MonospaceFont string `json:"monospace_font"`
73+ DefaultFontSize int `json:"default_font_size"`
74+ ColorScheme string `json:"color_scheme"`
75+ LightPalette ColorPalette `json:"light_palette"`
76+ DarkPalette ColorPalette `json:"dark_palette"`
77+}
78+
79+type ColorPalette struct {
80+ Background string `json:"background"`
81+ Surface string `json:"surface"`
82+ SurfaceVariant string `json:"surface_variant"`
83+ Text string `json:"text"`
84+ Muted string `json:"muted"`
85+ Accent string `json:"accent"`
86+ Border string `json:"border"`
87+}
88+
89+type SearchShortcut struct {
90+ URL string `json:"url"`
91+ Description string `json:"description"`
92+}
M internal/application/port/favicon_service.go +7 -0
@@ -9,6 +9,7 @@ type FaviconService interface {
9 DiskPathPNG(domain string) string9 DiskPathPNG(domain string) string
10 HasPNGOnDisk(domain string) bool10 HasPNGOnDisk(domain string) bool
11 HasPNGSizedOnDisk(domain string, size int) bool11 HasPNGSizedOnDisk(domain string, size int) bool
12+ DiskPathPNGSized(domain string, size int) string
12 EnsureSizedPNG(ctx context.Context, domain string, size int) error13 EnsureSizedPNG(ctx context.Context, domain string, size int) error
13 EnsureCacheDir() error14 EnsureCacheDir() error
14 // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given15 // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given
@@ -17,3 +18,9 @@ type FaviconService interface {
17 EnsureDiskCache(ctx context.Context, domain string)18 EnsureDiskCache(ctx context.Context, domain string)
18 Close()19 Close()
19 }20 }
21+
22+// SystemviewFaviconServiceSetter is an optional engine capability that lets the
23+// UI expose cached favicons to internal systemview pages without remote image loads.
24+type SystemviewFaviconServiceSetter interface {
25+ SetSystemviewFaviconService(service FaviconService)
26+}
M internal/application/port/handler_deps.go +2 -1
@@ -3,6 +3,7 @@ package port
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
7 )8 )
8 9
@@ -43,7 +44,7 @@ type AutoCopyConfig interface {
43 // A struct is used here to avoid reverse dependencies between the bootstrap and44 // A struct is used here to avoid reverse dependencies between the bootstrap and
44 // UI layers, which both need to reference this shared dependency container.45 // UI layers, which both need to reference this shared dependency container.
45 type HandlerDeps struct {46 type HandlerDeps struct {
46- SaveConfig func(context.Context, WebUIConfig) error47+ SaveConfig func(context.Context, dto.WebUIConfig) error
47 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error48 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
48 KeybindingsGetter KeybindingsGetter49 KeybindingsGetter KeybindingsGetter
49 KeybindingSetter KeybindingSetter50 KeybindingSetter KeybindingSetter
M internal/application/port/homepage.go +10 -13
@@ -4,27 +4,21 @@ import (
4 "context"4 "context"
5 "time"5 "time"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 )9 )
9 10
10-// HistorySearchInput holds search parameters (moved from usecase.SearchInput).
11-type HistorySearchInput struct {
12- Query string
13- Limit int
14-}
15-
16-// HistorySearchOutput holds search results (moved from usecase.SearchOutput).
17-type HistorySearchOutput struct {
18- Matches []entity.HistoryMatch
19-}
20-
21 // HomepageHistory provides history operations needed by the WebUI homepage handlers.11 // HomepageHistory provides history operations needed by the WebUI homepage handlers.
22 type HomepageHistory interface {12 type HomepageHistory interface {
23 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)13 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
24- Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)14+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15+ GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
16+ Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
25 Delete(ctx context.Context, id int64) error17 Delete(ctx context.Context, id int64) error
18+ ClearRange(ctx context.Context, rangeID string) error
26 ClearAll(ctx context.Context) error19 ClearAll(ctx context.Context) error
27 ClearOlderThan(ctx context.Context, before time.Time) error20 ClearOlderThan(ctx context.Context, before time.Time) error
21+ GetStats(ctx context.Context) (*entity.HistoryStats, error)
28 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)22 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)
29 GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)23 GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
30 DeleteByDomain(ctx context.Context, domain string) error24 DeleteByDomain(ctx context.Context, domain string) error
@@ -34,11 +28,14 @@ type HomepageHistory interface {
34 // needed by the WebUI homepage handlers.28 // needed by the WebUI homepage handlers.
35 type HomepageFavorites interface {29 type HomepageFavorites interface {
36 GetAll(ctx context.Context) ([]*entity.Favorite, error)30 GetAll(ctx context.Context) ([]*entity.Favorite, error)
31+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
32+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
33+ DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
37 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error34 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
38 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)35 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
39 Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error36 Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
40 GetAllFolders(ctx context.Context) ([]*entity.Folder, error)37 GetAllFolders(ctx context.Context) ([]*entity.Folder, error)
41- CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)38+ CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error)
42 DeleteFolder(ctx context.Context, id entity.FolderID) error39 DeleteFolder(ctx context.Context, id entity.FolderID) error
43 UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error40 UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error
44 GetAllTags(ctx context.Context) ([]*entity.Tag, error)41 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
M internal/application/port/mocks/mocks.go +709 -33
@@ -5052,6 +5052,63 @@ func (_c *MockFaviconService_DiskPathPNG_Call) RunAndReturn(run func(domain stri
5052 return _c5052 return _c
5053 }5053 }
5054 5054
5055+// DiskPathPNGSized provides a mock function for the type MockFaviconService
5056+func (_mock *MockFaviconService) DiskPathPNGSized(domain string, size int) string {
5057+ ret := _mock.Called(domain, size)
5058+
5059+ if len(ret) == 0 {
5060+ panic("no return value specified for DiskPathPNGSized")
5061+ }
5062+
5063+ var r0 string
5064+ if returnFunc, ok := ret.Get(0).(func(string, int) string); ok {
5065+ r0 = returnFunc(domain, size)
5066+ } else {
5067+ r0 = ret.Get(0).(string)
5068+ }
5069+ return r0
5070+}
5071+
5072+// MockFaviconService_DiskPathPNGSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiskPathPNGSized'
5073+type MockFaviconService_DiskPathPNGSized_Call struct {
5074+ *mock.Call
5075+}
5076+
5077+// DiskPathPNGSized is a helper method to define mock.On call
5078+// - domain string
5079+// - size int
5080+func (_e *MockFaviconService_Expecter) DiskPathPNGSized(domain interface{}, size interface{}) *MockFaviconService_DiskPathPNGSized_Call {
5081+ return &MockFaviconService_DiskPathPNGSized_Call{Call: _e.mock.On("DiskPathPNGSized", domain, size)}
5082+}
5083+
5084+func (_c *MockFaviconService_DiskPathPNGSized_Call) Run(run func(domain string, size int)) *MockFaviconService_DiskPathPNGSized_Call {
5085+ _c.Call.Run(func(args mock.Arguments) {
5086+ var arg0 string
5087+ if args[0] != nil {
5088+ arg0 = args[0].(string)
5089+ }
5090+ var arg1 int
5091+ if args[1] != nil {
5092+ arg1 = args[1].(int)
5093+ }
5094+ run(
5095+ arg0,
5096+ arg1,
5097+ )
5098+ })
5099+ return _c
5100+}
5101+
5102+func (_c *MockFaviconService_DiskPathPNGSized_Call) Return(s string) *MockFaviconService_DiskPathPNGSized_Call {
5103+ _c.Call.Return(s)
5104+ return _c
5105+}
5106+
5107+func (_c *MockFaviconService_DiskPathPNGSized_Call) RunAndReturn(run func(domain string, size int) string) *MockFaviconService_DiskPathPNGSized_Call {
5108+ _c.Call.Return(run)
5109+ return _c
5110+}
5111+
5055 // EnsureCacheDir provides a mock function for the type MockFaviconService5112 // EnsureCacheDir provides a mock function for the type MockFaviconService
5056 func (_mock *MockFaviconService) EnsureCacheDir() error {5113 func (_mock *MockFaviconService) EnsureCacheDir() error {
5057 ret := _mock.Called()5114 ret := _mock.Called()
@@ -6547,6 +6604,63 @@ func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx con
6547 return _c6604 return _c
6548 }6605 }
6549 6606
6607+// ClearRange provides a mock function for the type MockHomepageHistory
6608+func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
6609+ ret := _mock.Called(ctx, rangeID)
6610+
6611+ if len(ret) == 0 {
6612+ panic("no return value specified for ClearRange")
6613+ }
6614+
6615+ var r0 error
6616+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
6617+ r0 = returnFunc(ctx, rangeID)
6618+ } else {
6619+ r0 = ret.Error(0)
6620+ }
6621+ return r0
6622+}
6623+
6624+// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
6625+type MockHomepageHistory_ClearRange_Call struct {
6626+ *mock.Call
6627+}
6628+
6629+// ClearRange is a helper method to define mock.On call
6630+// - ctx context.Context
6631+// - rangeID string
6632+func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
6633+ return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
6634+}
6635+
6636+func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
6637+ _c.Call.Run(func(args mock.Arguments) {
6638+ var arg0 context.Context
6639+ if args[0] != nil {
6640+ arg0 = args[0].(context.Context)
6641+ }
6642+ var arg1 string
6643+ if args[1] != nil {
6644+ arg1 = args[1].(string)
6645+ }
6646+ run(
6647+ arg0,
6648+ arg1,
6649+ )
6650+ })
6651+ return _c
6652+}
6653+
6654+func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
6655+ _c.Call.Return(err)
6656+ return _c
6657+}
6658+
6659+func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
6660+ _c.Call.Return(run)
6661+ return _c
6662+}
6663+
6550 // Delete provides a mock function for the type MockHomepageHistory6664 // Delete provides a mock function for the type MockHomepageHistory
6551 func (_mock *MockHomepageHistory) Delete(ctx context.Context, id int64) error {6665 func (_mock *MockHomepageHistory) Delete(ctx context.Context, id int64) error {
6552 ret := _mock.Called(ctx, id)6666 ret := _mock.Called(ctx, id)
@@ -6865,27 +6979,243 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context.
6865 return _c6979 return _c
6866 }6980 }
6867 6981
6982+// GetRecentByDomain provides a mock function for the type MockHomepageHistory
6983+func (_mock *MockHomepageHistory) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
6984+ ret := _mock.Called(ctx, domain, limit, offset)
6985+
6986+ if len(ret) == 0 {
6987+ panic("no return value specified for GetRecentByDomain")
6988+ }
6989+
6990+ var r0 []*entity.HistoryEntry
6991+ var r1 error
6992+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
6993+ return returnFunc(ctx, domain, limit, offset)
6994+ }
6995+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
6996+ r0 = returnFunc(ctx, domain, limit, offset)
6997+ } else {
6998+ if ret.Get(0) != nil {
6999+ r0 = ret.Get(0).([]*entity.HistoryEntry)
7000+ }
7001+ }
7002+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
7003+ r1 = returnFunc(ctx, domain, limit, offset)
7004+ } else {
7005+ r1 = ret.Error(1)
7006+ }
7007+ return r0, r1
7008+}
7009+
7010+// MockHomepageHistory_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
7011+type MockHomepageHistory_GetRecentByDomain_Call struct {
7012+ *mock.Call
7013+}
7014+
7015+// GetRecentByDomain is a helper method to define mock.On call
7016+// - ctx context.Context
7017+// - domain string
7018+// - limit int
7019+// - offset int
7020+func (_e *MockHomepageHistory_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHomepageHistory_GetRecentByDomain_Call {
7021+ return &MockHomepageHistory_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
7022+}
7023+
7024+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHomepageHistory_GetRecentByDomain_Call {
7025+ _c.Call.Run(func(args mock.Arguments) {
7026+ var arg0 context.Context
7027+ if args[0] != nil {
7028+ arg0 = args[0].(context.Context)
7029+ }
7030+ var arg1 string
7031+ if args[1] != nil {
7032+ arg1 = args[1].(string)
7033+ }
7034+ var arg2 int
7035+ if args[2] != nil {
7036+ arg2 = args[2].(int)
7037+ }
7038+ var arg3 int
7039+ if args[3] != nil {
7040+ arg3 = args[3].(int)
7041+ }
7042+ run(
7043+ arg0,
7044+ arg1,
7045+ arg2,
7046+ arg3,
7047+ )
7048+ })
7049+ return _c
7050+}
7051+
7052+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHomepageHistory_GetRecentByDomain_Call {
7053+ _c.Call.Return(historyEntrys, err)
7054+ return _c
7055+}
7056+
7057+func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHomepageHistory_GetRecentByDomain_Call {
7058+ _c.Call.Return(run)
7059+ return _c
7060+}
7061+
7062+// GetRecentWindow provides a mock function for the type MockHomepageHistory
7063+func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
7064+ ret := _mock.Called(ctx, before, domain)
7065+
7066+ if len(ret) == 0 {
7067+ panic("no return value specified for GetRecentWindow")
7068+ }
7069+
7070+ var r0 *entity.HistoryWindow
7071+ var r1 error
7072+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok {
7073+ return returnFunc(ctx, before, domain)
7074+ }
7075+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok {
7076+ r0 = returnFunc(ctx, before, domain)
7077+ } else {
7078+ if ret.Get(0) != nil {
7079+ r0 = ret.Get(0).(*entity.HistoryWindow)
7080+ }
7081+ }
7082+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok {
7083+ r1 = returnFunc(ctx, before, domain)
7084+ } else {
7085+ r1 = ret.Error(1)
7086+ }
7087+ return r0, r1
7088+}
7089+
7090+// MockHomepageHistory_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow'
7091+type MockHomepageHistory_GetRecentWindow_Call struct {
7092+ *mock.Call
7093+}
7094+
7095+// GetRecentWindow is a helper method to define mock.On call
7096+// - ctx context.Context
7097+// - before time.Time
7098+// - domain string
7099+func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx interface{}, before interface{}, domain interface{}) *MockHomepageHistory_GetRecentWindow_Call {
7100+ return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)}
7101+}
7102+
7103+func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call {
7104+ _c.Call.Run(func(args mock.Arguments) {
7105+ var arg0 context.Context
7106+ if args[0] != nil {
7107+ arg0 = args[0].(context.Context)
7108+ }
7109+ var arg1 time.Time
7110+ if args[1] != nil {
7111+ arg1 = args[1].(time.Time)
7112+ }
7113+ var arg2 string
7114+ if args[2] != nil {
7115+ arg2 = args[2].(string)
7116+ }
7117+ run(
7118+ arg0,
7119+ arg1,
7120+ arg2,
7121+ )
7122+ })
7123+ return _c
7124+}
7125+
7126+func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity.HistoryWindow, err error) *MockHomepageHistory_GetRecentWindow_Call {
7127+ _c.Call.Return(historyWindow, err)
7128+ return _c
7129+}
7130+
7131+func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call {
7132+ _c.Call.Return(run)
7133+ return _c
7134+}
7135+
7136+// GetStats provides a mock function for the type MockHomepageHistory
7137+func (_mock *MockHomepageHistory) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
7138+ ret := _mock.Called(ctx)
7139+
7140+ if len(ret) == 0 {
7141+ panic("no return value specified for GetStats")
7142+ }
7143+
7144+ var r0 *entity.HistoryStats
7145+ var r1 error
7146+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.HistoryStats, error)); ok {
7147+ return returnFunc(ctx)
7148+ }
7149+ if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.HistoryStats); ok {
7150+ r0 = returnFunc(ctx)
7151+ } else {
7152+ if ret.Get(0) != nil {
7153+ r0 = ret.Get(0).(*entity.HistoryStats)
7154+ }
7155+ }
7156+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
7157+ r1 = returnFunc(ctx)
7158+ } else {
7159+ r1 = ret.Error(1)
7160+ }
7161+ return r0, r1
7162+}
7163+
7164+// MockHomepageHistory_GetStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStats'
7165+type MockHomepageHistory_GetStats_Call struct {
7166+ *mock.Call
7167+}
7168+
7169+// GetStats is a helper method to define mock.On call
7170+// - ctx context.Context
7171+func (_e *MockHomepageHistory_Expecter) GetStats(ctx interface{}) *MockHomepageHistory_GetStats_Call {
7172+ return &MockHomepageHistory_GetStats_Call{Call: _e.mock.On("GetStats", ctx)}
7173+}
7174+
7175+func (_c *MockHomepageHistory_GetStats_Call) Run(run func(ctx context.Context)) *MockHomepageHistory_GetStats_Call {
7176+ _c.Call.Run(func(args mock.Arguments) {
7177+ var arg0 context.Context
7178+ if args[0] != nil {
7179+ arg0 = args[0].(context.Context)
7180+ }
7181+ run(
7182+ arg0,
7183+ )
7184+ })
7185+ return _c
7186+}
7187+
7188+func (_c *MockHomepageHistory_GetStats_Call) Return(historyStats *entity.HistoryStats, err error) *MockHomepageHistory_GetStats_Call {
7189+ _c.Call.Return(historyStats, err)
7190+ return _c
7191+}
7192+
7193+func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.Context) (*entity.HistoryStats, error)) *MockHomepageHistory_GetStats_Call {
7194+ _c.Call.Return(run)
7195+ return _c
7196+}
7197+
6868 // Search provides a mock function for the type MockHomepageHistory7198 // Search provides a mock function for the type MockHomepageHistory
6869-func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {7199+func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
6870 ret := _mock.Called(ctx, input)7200 ret := _mock.Called(ctx, input)
6871 7201
6872 if len(ret) == 0 {7202 if len(ret) == 0 {
6873 panic("no return value specified for Search")7203 panic("no return value specified for Search")
6874 }7204 }
6875 7205
6876- var r0 *port.HistorySearchOutput7206+ var r0 *dto.HistorySearchOutput
6877 var r1 error7207 var r1 error
6878- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok {7208+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok {
6879 return returnFunc(ctx, input)7209 return returnFunc(ctx, input)
6880 }7210 }
6881- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok {7211+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok {
6882 r0 = returnFunc(ctx, input)7212 r0 = returnFunc(ctx, input)
6883 } else {7213 } else {
6884 if ret.Get(0) != nil {7214 if ret.Get(0) != nil {
6885- r0 = ret.Get(0).(*port.HistorySearchOutput)7215+ r0 = ret.Get(0).(*dto.HistorySearchOutput)
6886 }7216 }
6887 }7217 }
6888- if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok {7218+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok {
6889 r1 = returnFunc(ctx, input)7219 r1 = returnFunc(ctx, input)
6890 } else {7220 } else {
6891 r1 = ret.Error(1)7221 r1 = ret.Error(1)
@@ -6900,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct {
6900 7230
6901 // Search is a helper method to define mock.On call7231 // Search is a helper method to define mock.On call
6902 // - ctx context.Context7232 // - ctx context.Context
6903-// - input port.HistorySearchInput7233+// - input dto.HistorySearchInput
6904 func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call {7234 func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call {
6905 return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}7235 return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}
6906 }7236 }
6907 7237
6908-func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call {7238+func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call {
6909 _c.Call.Run(func(args mock.Arguments) {7239 _c.Call.Run(func(args mock.Arguments) {
6910 var arg0 context.Context7240 var arg0 context.Context
6911 if args[0] != nil {7241 if args[0] != nil {
6912 arg0 = args[0].(context.Context)7242 arg0 = args[0].(context.Context)
6913 }7243 }
6914- var arg1 port.HistorySearchInput7244+ var arg1 dto.HistorySearchInput
6915 if args[1] != nil {7245 if args[1] != nil {
6916- arg1 = args[1].(port.HistorySearchInput)7246+ arg1 = args[1].(dto.HistorySearchInput)
6917 }7247 }
6918 run(7248 run(
6919 arg0,7249 arg0,
@@ -6923,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp
6923 return _c7253 return _c
6924 }7254 }
6925 7255
6926-func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {7256+func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {
6927 _c.Call.Return(historySearchOutput, err)7257 _c.Call.Return(historySearchOutput, err)
6928 return _c7258 return _c
6929 }7259 }
6930 7260
6931-func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {7261+func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {
6932 _c.Call.Return(run)7262 _c.Call.Return(run)
6933 return _c7263 return _c
6934 }7264 }
@@ -6960,6 +7290,74 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter {
6960 return &MockHomepageFavorites_Expecter{mock: &_m.Mock}7290 return &MockHomepageFavorites_Expecter{mock: &_m.Mock}
6961 }7291 }
6962 7292
7293+// AddFavorite provides a mock function for the type MockHomepageFavorites
7294+func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
7295+ ret := _mock.Called(ctx, input)
7296+
7297+ if len(ret) == 0 {
7298+ panic("no return value specified for AddFavorite")
7299+ }
7300+
7301+ var r0 *entity.Favorite
7302+ var r1 error
7303+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error)); ok {
7304+ return returnFunc(ctx, input)
7305+ }
7306+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) *entity.Favorite); ok {
7307+ r0 = returnFunc(ctx, input)
7308+ } else {
7309+ if ret.Get(0) != nil {
7310+ r0 = ret.Get(0).(*entity.Favorite)
7311+ }
7312+ }
7313+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteCreateInput) error); ok {
7314+ r1 = returnFunc(ctx, input)
7315+ } else {
7316+ r1 = ret.Error(1)
7317+ }
7318+ return r0, r1
7319+}
7320+
7321+// MockHomepageFavorites_AddFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFavorite'
7322+type MockHomepageFavorites_AddFavorite_Call struct {
7323+ *mock.Call
7324+}
7325+
7326+// AddFavorite is a helper method to define mock.On call
7327+// - ctx context.Context
7328+// - input dto.FavoriteCreateInput
7329+func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {
7330+ return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}
7331+}
7332+
7333+func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
7334+ _c.Call.Run(func(args mock.Arguments) {
7335+ var arg0 context.Context
7336+ if args[0] != nil {
7337+ arg0 = args[0].(context.Context)
7338+ }
7339+ var arg1 dto.FavoriteCreateInput
7340+ if args[1] != nil {
7341+ arg1 = args[1].(dto.FavoriteCreateInput)
7342+ }
7343+ run(
7344+ arg0,
7345+ arg1,
7346+ )
7347+ })
7348+ return _c
7349+}
7350+
7351+func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_AddFavorite_Call {
7352+ _c.Call.Return(favorite, err)
7353+ return _c
7354+}
7355+
7356+func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
7357+ _c.Call.Return(run)
7358+ return _c
7359+}
7360+
6963 // AddTag provides a mock function for the type MockHomepageFavorites7361 // AddTag provides a mock function for the type MockHomepageFavorites
6964 func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) {7362 func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) {
6965 ret := _mock.Called(ctx, name, color)7363 ret := _mock.Called(ctx, name, color)
@@ -7035,8 +7433,8 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C
7035 }7433 }
7036 7434
7037 // CreateFolder provides a mock function for the type MockHomepageFavorites7435 // CreateFolder provides a mock function for the type MockHomepageFavorites
7038-func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {7436+func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
7039- ret := _mock.Called(ctx, name, parentID)7437+ ret := _mock.Called(ctx, name, icon, parentID)
7040 7438
7041 if len(ret) == 0 {7439 if len(ret) == 0 {
7042 panic("no return value specified for CreateFolder")7440 panic("no return value specified for CreateFolder")
@@ -7044,18 +7442,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin
7044 7442
7045 var r0 *entity.Folder7443 var r0 *entity.Folder
7046 var r1 error7444 var r1 error
7047- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok {7445+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok {
7048- return returnFunc(ctx, name, parentID)7446+ return returnFunc(ctx, name, icon, parentID)
7049 }7447 }
7050- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok {7448+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok {
7051- r0 = returnFunc(ctx, name, parentID)7449+ r0 = returnFunc(ctx, name, icon, parentID)
7052 } else {7450 } else {
7053 if ret.Get(0) != nil {7451 if ret.Get(0) != nil {
7054 r0 = ret.Get(0).(*entity.Folder)7452 r0 = ret.Get(0).(*entity.Folder)
7055 }7453 }
7056 }7454 }
7057- if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok {7455+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok {
7058- r1 = returnFunc(ctx, name, parentID)7456+ r1 = returnFunc(ctx, name, icon, parentID)
7059 } else {7457 } else {
7060 r1 = ret.Error(1)7458 r1 = ret.Error(1)
7061 }7459 }
@@ -7070,12 +7468,13 @@ type MockHomepageFavorites_CreateFolder_Call struct {
7070 // CreateFolder is a helper method to define mock.On call7468 // CreateFolder is a helper method to define mock.On call
7071 // - ctx context.Context7469 // - ctx context.Context
7072 // - name string7470 // - name string
7471+// - icon string
7073 // - parentID *entity.FolderID7472 // - parentID *entity.FolderID
7074-func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {7473+func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {
7075- return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)}7474+ return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)}
7076 }7475 }
7077 7476
7078-func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {7477+func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {
7079 _c.Call.Run(func(args mock.Arguments) {7478 _c.Call.Run(func(args mock.Arguments) {
7080 var arg0 context.Context7479 var arg0 context.Context
7081 if args[0] != nil {7480 if args[0] != nil {
@@ -7085,14 +7484,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont
7085 if args[1] != nil {7484 if args[1] != nil {
7086 arg1 = args[1].(string)7485 arg1 = args[1].(string)
7087 }7486 }
7088- var arg2 *entity.FolderID7487+ var arg2 string
7089 if args[2] != nil {7488 if args[2] != nil {
7090- arg2 = args[2].(*entity.FolderID)7489+ arg2 = args[2].(string)
7490+ }
7491+ var arg3 *entity.FolderID
7492+ if args[3] != nil {
7493+ arg3 = args[3].(*entity.FolderID)
7091 }7494 }
7092 run(7495 run(
7093 arg0,7496 arg0,
7094 arg1,7497 arg1,
7095 arg2,7498 arg2,
7499+ arg3,
7096 )7500 )
7097 })7501 })
7098 return _c7502 return _c
@@ -7103,7 +7507,64 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder,
7103 return _c7507 return _c
7104 }7508 }
7105 7509
7106-func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {7510+func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {
7511+ _c.Call.Return(run)
7512+ return _c
7513+}
7514+
7515+// DeleteFavorite provides a mock function for the type MockHomepageFavorites
7516+func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
7517+ ret := _mock.Called(ctx, id)
7518+
7519+ if len(ret) == 0 {
7520+ panic("no return value specified for DeleteFavorite")
7521+ }
7522+
7523+ var r0 error
7524+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID) error); ok {
7525+ r0 = returnFunc(ctx, id)
7526+ } else {
7527+ r0 = ret.Error(0)
7528+ }
7529+ return r0
7530+}
7531+
7532+// MockHomepageFavorites_DeleteFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFavorite'
7533+type MockHomepageFavorites_DeleteFavorite_Call struct {
7534+ *mock.Call
7535+}
7536+
7537+// DeleteFavorite is a helper method to define mock.On call
7538+// - ctx context.Context
7539+// - id entity.FavoriteID
7540+func (_e *MockHomepageFavorites_Expecter) DeleteFavorite(ctx interface{}, id interface{}) *MockHomepageFavorites_DeleteFavorite_Call {
7541+ return &MockHomepageFavorites_DeleteFavorite_Call{Call: _e.mock.On("DeleteFavorite", ctx, id)}
7542+}
7543+
7544+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Run(run func(ctx context.Context, id entity.FavoriteID)) *MockHomepageFavorites_DeleteFavorite_Call {
7545+ _c.Call.Run(func(args mock.Arguments) {
7546+ var arg0 context.Context
7547+ if args[0] != nil {
7548+ arg0 = args[0].(context.Context)
7549+ }
7550+ var arg1 entity.FavoriteID
7551+ if args[1] != nil {
7552+ arg1 = args[1].(entity.FavoriteID)
7553+ }
7554+ run(
7555+ arg0,
7556+ arg1,
7557+ )
7558+ })
7559+ return _c
7560+}
7561+
7562+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Return(err error) *MockHomepageFavorites_DeleteFavorite_Call {
7563+ _c.Call.Return(err)
7564+ return _c
7565+}
7566+
7567+func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID) error) *MockHomepageFavorites_DeleteFavorite_Call {
7107 _c.Call.Return(run)7568 _c.Call.Return(run)
7108 return _c7569 return _c
7109 }7570 }
@@ -7728,6 +8189,74 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co
7728 return _c8189 return _c
7729 }8190 }
7730 8191
8192+// UpdateFavorite provides a mock function for the type MockHomepageFavorites
8193+func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
8194+ ret := _mock.Called(ctx, input)
8195+
8196+ if len(ret) == 0 {
8197+ panic("no return value specified for UpdateFavorite")
8198+ }
8199+
8200+ var r0 *entity.Favorite
8201+ var r1 error
8202+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
8203+ return returnFunc(ctx, input)
8204+ }
8205+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) *entity.Favorite); ok {
8206+ r0 = returnFunc(ctx, input)
8207+ } else {
8208+ if ret.Get(0) != nil {
8209+ r0 = ret.Get(0).(*entity.Favorite)
8210+ }
8211+ }
8212+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteUpdateInput) error); ok {
8213+ r1 = returnFunc(ctx, input)
8214+ } else {
8215+ r1 = ret.Error(1)
8216+ }
8217+ return r0, r1
8218+}
8219+
8220+// MockHomepageFavorites_UpdateFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFavorite'
8221+type MockHomepageFavorites_UpdateFavorite_Call struct {
8222+ *mock.Call
8223+}
8224+
8225+// UpdateFavorite is a helper method to define mock.On call
8226+// - ctx context.Context
8227+// - input dto.FavoriteUpdateInput
8228+func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {
8229+ return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}
8230+}
8231+
8232+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
8233+ _c.Call.Run(func(args mock.Arguments) {
8234+ var arg0 context.Context
8235+ if args[0] != nil {
8236+ arg0 = args[0].(context.Context)
8237+ }
8238+ var arg1 dto.FavoriteUpdateInput
8239+ if args[1] != nil {
8240+ arg1 = args[1].(dto.FavoriteUpdateInput)
8241+ }
8242+ run(
8243+ arg0,
8244+ arg1,
8245+ )
8246+ })
8247+ return _c
8248+}
8249+
8250+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_UpdateFavorite_Call {
8251+ _c.Call.Return(favorite, err)
8252+ return _c
8253+}
8254+
8255+func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
8256+ _c.Call.Return(run)
8257+ return _c
8258+}
8259+
7731 // UpdateFolder provides a mock function for the type MockHomepageFavorites8260 // UpdateFolder provides a mock function for the type MockHomepageFavorites
7732 func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {8261 func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {
7733 ret := _mock.Called(ctx, id, name, icon)8262 ret := _mock.Called(ctx, id, name, icon)
@@ -11749,6 +12278,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context.
11749 return _c12278 return _c
11750 }12279 }
11751 12280
12281+// 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.
12282+// The first argument is typically a *testing.T value.
12283+func NewMockSystemviewConfigReader(t interface {
12284+ mock.TestingT
12285+ Cleanup(func())
12286+}) *MockSystemviewConfigReader {
12287+ mock := &MockSystemviewConfigReader{}
12288+ mock.Mock.Test(t)
12289+
12290+ t.Cleanup(func() { mock.AssertExpectations(t) })
12291+
12292+ return mock
12293+}
12294+
12295+// MockSystemviewConfigReader is an autogenerated mock type for the SystemviewConfigReader type
12296+type MockSystemviewConfigReader struct {
12297+ mock.Mock
12298+}
12299+
12300+type MockSystemviewConfigReader_Expecter struct {
12301+ mock *mock.Mock
12302+}
12303+
12304+func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expecter {
12305+ return &MockSystemviewConfigReader_Expecter{mock: &_m.Mock}
12306+}
12307+
12308+// Current provides a mock function for the type MockSystemviewConfigReader
12309+func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
12310+ ret := _mock.Called(ctx)
12311+
12312+ if len(ret) == 0 {
12313+ panic("no return value specified for Current")
12314+ }
12315+
12316+ var r0 dto.SystemviewConfigPayload
12317+ var r1 error
12318+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
12319+ return returnFunc(ctx)
12320+ }
12321+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
12322+ r0 = returnFunc(ctx)
12323+ } else {
12324+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
12325+ }
12326+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12327+ r1 = returnFunc(ctx)
12328+ } else {
12329+ r1 = ret.Error(1)
12330+ }
12331+ return r0, r1
12332+}
12333+
12334+// MockSystemviewConfigReader_Current_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Current'
12335+type MockSystemviewConfigReader_Current_Call struct {
12336+ *mock.Call
12337+}
12338+
12339+// Current is a helper method to define mock.On call
12340+// - ctx context.Context
12341+func (_e *MockSystemviewConfigReader_Expecter) Current(ctx interface{}) *MockSystemviewConfigReader_Current_Call {
12342+ return &MockSystemviewConfigReader_Current_Call{Call: _e.mock.On("Current", ctx)}
12343+}
12344+
12345+func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Current_Call {
12346+ _c.Call.Run(func(args mock.Arguments) {
12347+ var arg0 context.Context
12348+ if args[0] != nil {
12349+ arg0 = args[0].(context.Context)
12350+ }
12351+ run(
12352+ arg0,
12353+ )
12354+ })
12355+ return _c
12356+}
12357+
12358+func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {
12359+ _c.Call.Return(systemviewConfigPayload, err)
12360+ return _c
12361+}
12362+
12363+func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {
12364+ _c.Call.Return(run)
12365+ return _c
12366+}
12367+
12368+// Default provides a mock function for the type MockSystemviewConfigReader
12369+func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
12370+ ret := _mock.Called(ctx)
12371+
12372+ if len(ret) == 0 {
12373+ panic("no return value specified for Default")
12374+ }
12375+
12376+ var r0 dto.SystemviewConfigPayload
12377+ var r1 error
12378+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
12379+ return returnFunc(ctx)
12380+ }
12381+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
12382+ r0 = returnFunc(ctx)
12383+ } else {
12384+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
12385+ }
12386+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12387+ r1 = returnFunc(ctx)
12388+ } else {
12389+ r1 = ret.Error(1)
12390+ }
12391+ return r0, r1
12392+}
12393+
12394+// MockSystemviewConfigReader_Default_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Default'
12395+type MockSystemviewConfigReader_Default_Call struct {
12396+ *mock.Call
12397+}
12398+
12399+// Default is a helper method to define mock.On call
12400+// - ctx context.Context
12401+func (_e *MockSystemviewConfigReader_Expecter) Default(ctx interface{}) *MockSystemviewConfigReader_Default_Call {
12402+ return &MockSystemviewConfigReader_Default_Call{Call: _e.mock.On("Default", ctx)}
12403+}
12404+
12405+func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Default_Call {
12406+ _c.Call.Run(func(args mock.Arguments) {
12407+ var arg0 context.Context
12408+ if args[0] != nil {
12409+ arg0 = args[0].(context.Context)
12410+ }
12411+ run(
12412+ arg0,
12413+ )
12414+ })
12415+ return _c
12416+}
12417+
12418+func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {
12419+ _c.Call.Return(systemviewConfigPayload, err)
12420+ return _c
12421+}
12422+
12423+func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {
12424+ _c.Call.Return(run)
12425+ return _c
12426+}
12427+
11752 // 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.12428 // 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.
11753 // The first argument is typically a *testing.T value.12429 // The first argument is typically a *testing.T value.
11754 func NewMockWebUIConfigSaver(t interface {12430 func NewMockWebUIConfigSaver(t interface {
@@ -11777,7 +12453,7 @@ func (_m *MockWebUIConfigSaver) EXPECT() *MockWebUIConfigSaver_Expecter {
11777 }12453 }
11778 12454
11779 // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver12455 // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver
11780-func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {12456+func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
11781 ret := _mock.Called(ctx, cfg)12457 ret := _mock.Called(ctx, cfg)
11782 12458
11783 if len(ret) == 0 {12459 if len(ret) == 0 {
@@ -11785,7 +12461,7 @@ func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port
11785 }12461 }
11786 12462
11787 var r0 error12463 var r0 error
11788- if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebUIConfig) error); ok {12464+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.WebUIConfig) error); ok {
11789 r0 = returnFunc(ctx, cfg)12465 r0 = returnFunc(ctx, cfg)
11790 } else {12466 } else {
11791 r0 = ret.Error(0)12467 r0 = ret.Error(0)
@@ -11800,20 +12476,20 @@ type MockWebUIConfigSaver_SaveWebUIConfig_Call struct {
11800 12476
11801 // SaveWebUIConfig is a helper method to define mock.On call12477 // SaveWebUIConfig is a helper method to define mock.On call
11802 // - ctx context.Context12478 // - ctx context.Context
11803-// - cfg port.WebUIConfig12479+// - cfg dto.WebUIConfig
11804 func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call {12480 func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
11805 return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)}12481 return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)}
11806 }12482 }
11807 12483
11808-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg port.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {12484+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg dto.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
11809 _c.Call.Run(func(args mock.Arguments) {12485 _c.Call.Run(func(args mock.Arguments) {
11810 var arg0 context.Context12486 var arg0 context.Context
11811 if args[0] != nil {12487 if args[0] != nil {
11812 arg0 = args[0].(context.Context)12488 arg0 = args[0].(context.Context)
11813 }12489 }
11814- var arg1 port.WebUIConfig12490+ var arg1 dto.WebUIConfig
11815 if args[1] != nil {12491 if args[1] != nil {
11816- arg1 = args[1].(port.WebUIConfig)12492+ arg1 = args[1].(dto.WebUIConfig)
11817 }12493 }
11818 run(12494 run(
11819 arg0,12495 arg0,
@@ -11828,7 +12504,7 @@ func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Return(err error) *MockWebU
11828 return _c12504 return _c
11829 }12505 }
11830 12506
11831-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg port.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {12507+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg dto.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
11832 _c.Call.Return(run)12508 _c.Call.Return(run)
11833 return _c12509 return _c
11834 }12510 }
A internal/application/port/systemviews.go +54 -0
@@ -0,0 +1,54 @@
1+package port
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// SystemviewHistoryService exposes history operations for the systemviews history route.
12+type SystemviewHistoryService interface {
13+ Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
14+ TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15+ TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
16+ Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
17+ DeleteEntry(ctx context.Context, id int64) error
18+ DeleteRange(ctx context.Context, rangeID string) error
19+ Stats(ctx context.Context) (*entity.HistoryStats, error)
20+ Analytics(ctx context.Context) (*entity.HistoryAnalytics, error)
21+ DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
22+ DeleteDomain(ctx context.Context, domain string) error
23+}
24+
25+// SystemviewFavoritesService exposes favorite, folder, and tag operations.
26+type SystemviewFavoritesService interface {
27+ List(ctx context.Context) ([]*entity.Favorite, error)
28+ CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
29+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
30+ DeleteFavorite(ctx context.Context, id int64) error
31+ ListFolders(ctx context.Context) ([]*entity.Folder, error)
32+ ListTags(ctx context.Context) ([]*entity.Tag, error)
33+ SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
34+ SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error
35+ CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error)
36+ UpdateFolder(ctx context.Context, id int64, name, icon string) error
37+ DeleteFolder(ctx context.Context, id int64) error
38+ CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
39+ UpdateTag(ctx context.Context, id int64, name, color string) error
40+ DeleteTag(ctx context.Context, id int64) error
41+ AssignTag(ctx context.Context, favoriteID, tagID int64) error
42+ RemoveTag(ctx context.Context, favoriteID, tagID int64) error
43+}
44+
45+// SystemviewConfigService exposes config and keybinding operations for the systemviews UI.
46+type SystemviewConfigService interface {
47+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
48+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
49+ Save(ctx context.Context, cfg dto.WebUIConfig) error
50+ GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
51+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
52+ ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
53+ ResetAllKeybindings(ctx context.Context) error
54+}
M internal/application/port/webui_config.go +9 -48
@@ -1,57 +1,18 @@
1 package port1 package port
2 2
3-import "context"3+import (
4+ "context"
4 5
5-// WebUIConfig represents the subset of configuration editable in dumb://config.6+ "github.com/bnema/dumber/internal/application/dto"
6-type WebUIConfig struct {7+)
7- Appearance WebUIAppearanceConfig `json:"appearance"`
8- Performance WebUIPerformanceConfig `json:"performance"`
9- DefaultUIScale float64 `json:"default_ui_scale"`
10- DefaultSearchEngine string `json:"default_search_engine"`
11- SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
12-}
13-
14-// WebUIPerformanceConfig represents performance settings editable in dumb://config.
15-type WebUIPerformanceConfig struct {
16- Profile string `json:"profile"`
17- Custom WebUICustomPerformanceConfig `json:"custom"`
18-}
19-
20-// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
21-type WebUICustomPerformanceConfig struct {
22- SkiaCPUThreads int `json:"skia_cpu_threads"`
23- SkiaGPUThreads int `json:"skia_gpu_threads"`
24- WebProcessMemoryMB int `json:"web_process_memory_mb"`
25- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
26- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
27-}
28-
29-type WebUIAppearanceConfig struct {
30- SansFont string `json:"sans_font"`
31- SerifFont string `json:"serif_font"`
32- MonospaceFont string `json:"monospace_font"`
33- DefaultFontSize int `json:"default_font_size"`
34- ColorScheme string `json:"color_scheme"`
35- LightPalette ColorPalette `json:"light_palette"`
36- DarkPalette ColorPalette `json:"dark_palette"`
37-}
38-
39-type ColorPalette struct {
40- Background string `json:"background"`
41- Surface string `json:"surface"`
42- SurfaceVariant string `json:"surface_variant"`
43- Text string `json:"text"`
44- Muted string `json:"muted"`
45- Accent string `json:"accent"`
46- Border string `json:"border"`
47-}
48 8
49-type SearchShortcut struct {9+// SystemviewConfigReader reads the current and default systemview config payloads.
50- URL string `json:"url"`10+type SystemviewConfigReader interface {
51- Description string `json:"description"`11+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
12+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
52 }13 }
53 14
54 // WebUIConfigSaver persists WebUI configuration changes.15 // WebUIConfigSaver persists WebUI configuration changes.
55 type WebUIConfigSaver interface {16 type WebUIConfigSaver interface {
56- SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error17+ SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error
57 }18 }
M internal/application/usecase/manage_favorites.go +161 -24
@@ -3,11 +3,13 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "net/url"
6 "sort"7 "sort"
7 "strings"8 "strings"
8 "sync"9 "sync"
9 "time"10 "time"
10 11
12+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/domain/repository"15 "github.com/bnema/dumber/internal/domain/repository"
@@ -69,6 +71,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
69 return existing, nil71 return existing, nil
70 }72 }
71 73
74+ return uc.addNewFavorite(ctx, input)
75+}
76+
77+func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
78+ log := logging.FromContext(ctx)
72 fav := entity.NewFavorite(input.URL, input.Title)79 fav := entity.NewFavorite(input.URL, input.Title)
73 fav.FaviconURL = input.FaviconURL80 fav.FaviconURL = input.FaviconURL
74 fav.FolderID = input.FolderID81 fav.FolderID = input.FolderID
@@ -93,6 +100,124 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
93 return fav, nil100 return fav, nil
94 }101 }
95 102
103+// AddFavorite creates a favorite from the UI-facing application port.
104+func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
105+ for _, tagID := range input.Tags {
106+ if tagID <= 0 {
107+ return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID)
108+ }
109+ }
110+ favoriteURL, err := normalizeFavoriteURL(input.URL)
111+ if err != nil {
112+ return nil, err
113+ }
114+ if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil {
115+ return nil, err
116+ } else if existing != nil {
117+ return existing, nil
118+ }
119+ tags := append([]entity.TagID(nil), input.Tags...)
120+ title := strings.TrimSpace(input.Title)
121+ if title == "" {
122+ title = favoriteURL
123+ }
124+ return uc.addNewFavorite(ctx, AddFavoriteInput{
125+ URL: favoriteURL,
126+ Title: title,
127+ FaviconURL: strings.TrimSpace(input.FaviconURL),
128+ FolderID: input.FolderID,
129+ Tags: tags,
130+ })
131+}
132+
133+func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(
134+ ctx context.Context,
135+ canonicalURL string,
136+ rawURL string,
137+) (*entity.Favorite, error) {
138+ existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
141+ }
142+ if existing != nil {
143+ return existing, nil
144+ }
145+ rawURL = strings.TrimSpace(rawURL)
146+ if rawURL == "" || rawURL == canonicalURL {
147+ return nil, nil
148+ }
149+ existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL)
150+ if err != nil {
151+ return nil, fmt.Errorf("failed to check legacy favorite: %w", err)
152+ }
153+ return existing, nil
154+}
155+
156+func normalizeFavoriteURL(raw string) (string, error) {
157+ favoriteURL := domainurl.Normalize(strings.TrimSpace(raw))
158+ if favoriteURL == "" {
159+ return "", fmt.Errorf("favorite URL is required")
160+ }
161+ parsed, err := url.Parse(favoriteURL)
162+ if err != nil || parsed.Scheme == "" {
163+ return "", fmt.Errorf("favorite URL must be absolute")
164+ }
165+ switch strings.ToLower(parsed.Scheme) {
166+ case "http", "https":
167+ if parsed.Host == "" {
168+ return "", fmt.Errorf("favorite URL host is required")
169+ }
170+ case "dumb":
171+ if parsed.Host != "" {
172+ return favoriteURL, nil
173+ }
174+ if parsed.Opaque != "" {
175+ return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
176+ }
177+ return "", fmt.Errorf("favorite URL host is required")
178+ default:
179+ return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
180+ }
181+ return favoriteURL, nil
182+}
183+
184+// UpdateFavorite updates editable favorite metadata from the UI-facing application port.
185+func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
186+ if input.ID <= 0 {
187+ return nil, fmt.Errorf("favorite ID is required")
188+ }
189+ if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
190+ return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
191+ }
192+
193+ fav, err := uc.favoriteRepo.FindByID(ctx, input.ID)
194+ if err != nil {
195+ return nil, fmt.Errorf("failed to find favorite: %w", err)
196+ }
197+ if fav == nil {
198+ return nil, fmt.Errorf("favorite %d not found", input.ID)
199+ }
200+
201+ title := strings.TrimSpace(input.Title)
202+ if title == "" {
203+ return nil, fmt.Errorf("favorite title is required")
204+ }
205+ fav.Title = title
206+ fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
207+ fav.FolderID = input.FolderID
208+ fav.ShortcutKey = input.ShortcutKey
209+
210+ if err := uc.Update(ctx, fav); err != nil {
211+ return nil, err
212+ }
213+ return fav, nil
214+}
215+
216+// DeleteFavorite deletes a favorite from the UI-facing application port.
217+func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
218+ return uc.Remove(ctx, id)
219+}
220+
96 // Remove deletes a favorite by ID.221 // Remove deletes a favorite by ID.
97 func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {222 func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
98 log := logging.FromContext(ctx)223 log := logging.FromContext(ctx)
@@ -108,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
108 }233 }
109 234
110 // RemoveByURL deletes a favorite by its URL.235 // RemoveByURL deletes a favorite by its URL.
111-func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {236+func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
112 log := logging.FromContext(ctx)237 log := logging.FromContext(ctx)
113- log.Debug().Str("url", url).Msg("removing favorite by URL")238+ log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")
114 239
115- fav, err := uc.favoriteRepo.FindByURL(ctx, url)240+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
116 if err != nil {241 if err != nil {
117 return fmt.Errorf("failed to find favorite: %w", err)242 return fmt.Errorf("failed to find favorite: %w", err)
118 }243 }
119 if fav == nil {244 if fav == nil {
120- log.Debug().Str("url", url).Msg("favorite not found")245+ log.Debug().Str("url", favoriteURL).Msg("favorite not found")
121 return nil246 return nil
122 }247 }
123 248
@@ -187,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
187 }312 }
188 313
189 // GetByURL finds a favorite by its URL.314 // GetByURL finds a favorite by its URL.
190-func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {315+func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
191 log := logging.FromContext(ctx)316 log := logging.FromContext(ctx)
192- log.Debug().Str("url", url).Msg("getting favorite by URL")317+ log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")
193 318
194- return uc.favoriteRepo.FindByURL(ctx, url)319+ return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
195 }320 }
196 321
197 // GetAll retrieves all favorites.322 // GetAll retrieves all favorites.
@@ -268,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st
268 }393 }
269 394
270 // IsFavorite checks if a URL is favorited.395 // IsFavorite checks if a URL is favorited.
271-func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {396+func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) {
272- fav, err := uc.favoriteRepo.FindByURL(ctx, url)397+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
273 if err != nil {398 if err != nil {
274 return false, fmt.Errorf("failed to check favorite: %w", err)399 return false, fmt.Errorf("failed to check favorite: %w", err)
275 }400 }
@@ -322,18 +447,20 @@ type ToggleResult struct {
322 447
323 // Toggle adds or removes a URL from favorites based on current state.448 // Toggle adds or removes a URL from favorites based on current state.
324 // If the URL is already a favorite, it removes it. Otherwise, it adds it.449 // If the URL is already a favorite, it removes it. Otherwise, it adds it.
325-func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {450+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) {
326- log := logging.FromContext(ctx)451+ favoriteURL, err := normalizeFavoriteURL(rawURL)
327- log.Debug().Str("url", url).Msg("toggling favorite")452+ if err != nil {
328-453+ return nil, err
329- if url == "" {
330- return nil, fmt.Errorf("URL cannot be empty")
331 }454 }
455+ title = strings.TrimSpace(title)
332 456
333- // Check if already favorited457+ log := logging.FromContext(ctx)
334- existing, err := uc.favoriteRepo.FindByURL(ctx, url)458+ log.Debug().Str("url", favoriteURL).Msg("toggling favorite")
459+
460+ // Check if already favorited, including legacy rows stored before URL canonicalization.
461+ existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
335 if err != nil {462 if err != nil {
336- return nil, fmt.Errorf("failed to check existing favorite: %w", err)463+ return nil, err
337 }464 }
338 465
339 if existing != nil {466 if existing != nil {
@@ -341,26 +468,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
341 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
342 return nil, fmt.Errorf("failed to remove favorite: %w", err)469 return nil, fmt.Errorf("failed to remove favorite: %w", err)
343 }470 }
344- log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")471+ log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
345 uc.invalidateCache()472 uc.invalidateCache()
346 return &ToggleResult{473 return &ToggleResult{
347 Added: false,474 Added: false,
348- URL: url,475+ URL: favoriteURL,
349 Title: existing.Title,476 Title: existing.Title,
350 Message: "Favorite removed",477 Message: "Favorite removed",
351 }, nil478 }, nil
352 }479 }
353 480
354 // Add favorite481 // Add favorite
355- fav := entity.NewFavorite(url, title)482+ fav := entity.NewFavorite(favoriteURL, title)
356 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
357 return nil, fmt.Errorf("failed to add favorite: %w", err)484 return nil, fmt.Errorf("failed to add favorite: %w", err)
358 }485 }
359- log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")486+ log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
360 uc.invalidateCache()487 uc.invalidateCache()
361 return &ToggleResult{488 return &ToggleResult{
362 Added: true,489 Added: true,
363- URL: url,490+ URL: favoriteURL,
364 Title: title,491 Title: title,
365 Message: "Favorite added",492 Message: "Favorite added",
366 }, nil493 }, nil
@@ -456,11 +583,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite
456 }583 }
457 584
458 // CreateFolder creates a new folder.585 // CreateFolder creates a new folder.
459-func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {586+func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
587+ name = strings.TrimSpace(name)
588+ icon = strings.TrimSpace(icon)
589+ if name == "" {
590+ return nil, fmt.Errorf("folder name is required")
591+ }
460 log := logging.FromContext(ctx)592 log := logging.FromContext(ctx)
461 log.Debug().Str("name", name).Msg("creating folder")593 log.Debug().Str("name", name).Msg("creating folder")
462 594
463 folder := entity.NewFolder(name)595 folder := entity.NewFolder(name)
596+ folder.Icon = icon
464 folder.ParentID = parentID597 folder.ParentID = parentID
465 598
466 if err := uc.folderRepo.Save(ctx, folder); err != nil {599 if err := uc.folderRepo.Save(ctx, folder); err != nil {
@@ -574,6 +707,7 @@ func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID
574 }707 }
575 708
576 log.Info().Int64("id", int64(id)).Msg("tag deleted")709 log.Info().Int64("id", int64(id)).Msg("tag deleted")
710+ uc.invalidateCache()
577 return nil711 return nil
578 }712 }
579 713
@@ -596,6 +730,7 @@ func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.
596 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {730 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
597 return fmt.Errorf("failed to tag favorite: %w", err)731 return fmt.Errorf("failed to tag favorite: %w", err)
598 }732 }
733+ uc.invalidateCache()
599 734
600 return nil735 return nil
601 }736 }
@@ -611,6 +746,7 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit
611 if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {746 if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
612 return fmt.Errorf("failed to untag favorite: %w", err)747 return fmt.Errorf("failed to untag favorite: %w", err)
613 }748 }
749+ uc.invalidateCache()
614 750
615 return nil751 return nil
616 }752 }
@@ -674,5 +810,6 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID
674 }810 }
675 811
676 log.Info().Int64("id", int64(id)).Msg("tag updated")812 log.Info().Int64("id", int64(id)).Msg("tag updated")
813+ uc.invalidateCache()
677 return nil814 return nil
678 }815 }
M internal/application/usecase/manage_favorites_test.go +105 -1
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"10 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
142 result, err := uc.Toggle(ctx, "", "Title")143 result, err := uc.Toggle(ctx, "", "Title")
143 require.Error(t, err)144 require.Error(t, err)
144 assert.Nil(t, result)145 assert.Nil(t, result)
145- assert.Contains(t, err.Error(), "URL cannot be empty")146+ assert.Contains(t, err.Error(), "favorite URL is required")
147+}
148+
149+func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
150+ ctx := testContext()
151+
152+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
153+ folderRepo := repomocks.NewMockFolderRepository(t)
154+ tagRepo := repomocks.NewMockTagRepository(t)
155+
156+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
157+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
158+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
159+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
160+ })).Return(nil).Once()
161+
162+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
163+
164+ result, err := uc.Toggle(ctx, " example.com ", " Example ")
165+ require.NoError(t, err)
166+ require.NotNil(t, result)
167+ assert.True(t, result.Added)
168+ assert.Equal(t, "https://example.com", result.URL)
169+ assert.Equal(t, "Example", result.Title)
146 }170 }
147 171
148 func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {172 func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
@@ -342,3 +366,83 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin
342 require.Len(t, favoritesAfter, 1)366 require.Len(t, favoritesAfter, 1)
343 assert.Nil(t, favoritesAfter[0].FolderID)367 assert.Nil(t, favoritesAfter[0].FolderID)
344 }368 }
369+
370+func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
371+ ctx := testContext()
372+
373+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
374+ folderRepo := repomocks.NewMockFolderRepository(t)
375+ tagRepo := repomocks.NewMockTagRepository(t)
376+
377+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
378+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
379+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
380+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
381+ })).Return(nil).Once()
382+
383+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
384+
385+ fav, err := uc.AddFavorite(ctx, dto.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_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393+ ctx := testContext()
394+
395+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396+ folderRepo := repomocks.NewMockFolderRepository(t)
397+ tagRepo := repomocks.NewMockTagRepository(t)
398+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
399+
400+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
401+ require.Error(t, err)
402+ assert.Contains(t, err.Error(), "favorite tag id must be positive")
403+}
404+
405+func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
406+ ctx := testContext()
407+
408+ tests := []string{
409+ "",
410+ "not a url",
411+ "javascript:alert(1)",
412+ "data:text/html,<script>alert(1)</script>",
413+ "https:///missing-host",
414+ "dumb:///missing-host",
415+ }
416+ for _, raw := range tests {
417+ t.Run(raw, func(t *testing.T) {
418+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
419+ folderRepo := repomocks.NewMockFolderRepository(t)
420+ tagRepo := repomocks.NewMockTagRepository(t)
421+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
422+
423+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
424+ require.Error(t, err)
425+ })
426+ }
427+}
428+
429+func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) {
430+ ctx := testContext()
431+
432+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
433+ folderRepo := repomocks.NewMockFolderRepository(t)
434+ tagRepo := repomocks.NewMockTagRepository(t)
435+
436+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()
437+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once()
438+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
439+ return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"
440+ })).Return(nil).Once()
441+
442+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
443+
444+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
445+ require.NoError(t, err)
446+ require.NotNil(t, fav)
447+ assert.Equal(t, "dumb://history", fav.URL)
448+}
A internal/application/usecase/read_systemview_config.go +41 -0
@@ -0,0 +1,41 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// ReadSystemviewConfigUseCase exposes read-side systemview config payloads.
12+type ReadSystemviewConfigUseCase struct {
13+ reader port.SystemviewConfigReader
14+}
15+
16+// NewReadSystemviewConfigUseCase creates a new read systemview config use case.
17+func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase {
18+ if reader == nil {
19+ panic("NewReadSystemviewConfigUseCase: reader is nil")
20+ }
21+ return &ReadSystemviewConfigUseCase{reader: reader}
22+}
23+
24+// ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.
25+var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")
26+
27+// Current returns the current systemview config payload.
28+func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
29+ if uc == nil || uc.reader == nil {
30+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
31+ }
32+ return uc.reader.Current(ctx)
33+}
34+
35+// Default returns the default systemview config payload.
36+func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
37+ if uc == nil || uc.reader == nil {
38+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
39+ }
40+ return uc.reader.Default(ctx)
41+}
A internal/application/usecase/read_systemview_config_test.go +51 -0
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
14+ reader := portmocks.NewMockSystemviewConfigReader(t)
15+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
16+ reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()
17+
18+ uc := NewReadSystemviewConfigUseCase(reader)
19+
20+ got, err := uc.Current(context.Background())
21+ require.NoError(t, err)
22+ require.Equal(t, want, got)
23+}
24+
25+func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
26+ reader := portmocks.NewMockSystemviewConfigReader(t)
27+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
28+ reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()
29+
30+ uc := NewReadSystemviewConfigUseCase(reader)
31+
32+ got, err := uc.Default(context.Background())
33+ require.NoError(t, err)
34+ require.Equal(t, want, got)
35+}
36+
37+func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) {
38+ require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() {
39+ NewReadSystemviewConfigUseCase(nil)
40+ })
41+}
42+
43+func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) {
44+ var uc *ReadSystemviewConfigUseCase
45+
46+ _, err := uc.Current(context.Background())
47+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
48+
49+ _, err = uc.Default(context.Background())
50+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
51+}
M internal/application/usecase/save_webui_config.go +43 -13
@@ -3,8 +3,10 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
6 "strings"7 "strings"
7 8
9+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/validation"11 "github.com/bnema/dumber/internal/domain/validation"
10 )12 )
@@ -17,11 +19,15 @@ func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseC
17 return &SaveWebUIConfigUseCase{saver: saver}19 return &SaveWebUIConfigUseCase{saver: saver}
18 }20 }
19 21
20-func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error {22+func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
21 if uc == nil || uc.saver == nil {23 if uc == nil || uc.saver == nil {
22 return fmt.Errorf("config saver is nil")24 return fmt.Errorf("config saver is nil")
23 }25 }
24 26
27+ if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28+ return err
29+ }
30+
25 normalized := normalizeWebUIConfig(cfg)31 normalized := normalizeWebUIConfig(cfg)
26 if err := validateWebUIConfig(normalized); err != nil {32 if err := validateWebUIConfig(normalized); err != nil {
27 return err33 return err
@@ -30,45 +36,59 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUICon
30 return uc.saver.SaveWebUIConfig(ctx, normalized)36 return uc.saver.SaveWebUIConfig(ctx, normalized)
31 }37 }
32 38
33-func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {39+func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error {
40+ seen := make(map[string]string, len(shortcuts))
41+ for key := range shortcuts {
42+ trimmedKey := strings.TrimSpace(key)
43+ if trimmedKey == "" {
44+ continue
45+ }
46+ if previous, ok := seen[trimmedKey]; ok && previous != key {
47+ return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous)
48+ }
49+ seen[trimmedKey] = key
50+ }
51+ return nil
52+}
53+
54+func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
34 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)55 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
35 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)56 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
36 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)57 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
37 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)58 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
38 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)59 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
39 if len(cfg.SearchShortcuts) > 0 {60 if len(cfg.SearchShortcuts) > 0 {
40- normalized := make(map[string]port.SearchShortcut, len(cfg.SearchShortcuts))61+ normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
41 for key, shortcut := range cfg.SearchShortcuts {62 for key, shortcut := range cfg.SearchShortcuts {
42 trimmedKey := strings.TrimSpace(key)63 trimmedKey := strings.TrimSpace(key)
64+ if trimmedKey == "" {
65+ continue
66+ }
43 shortcut.URL = strings.TrimSpace(shortcut.URL)67 shortcut.URL = strings.TrimSpace(shortcut.URL)
44 shortcut.Description = strings.TrimSpace(shortcut.Description)68 shortcut.Description = strings.TrimSpace(shortcut.Description)
45- if trimmedKey != "" {69+ normalized[trimmedKey] = shortcut
46- normalized[trimmedKey] = shortcut
47- }
48 }70 }
49 cfg.SearchShortcuts = normalized71 cfg.SearchShortcuts = normalized
50 }72 }
51 return cfg73 return cfg
52 }74 }
53 75
54-func validateWebUIConfig(cfg port.WebUIConfig) error {76+func validateWebUIConfig(cfg dto.WebUIConfig) error {
55 var errs []string77 var errs []string
56 78
57 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {79 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
58 errs = append(errs, "appearance.default_font_size must be between 1 and 72")80 errs = append(errs, "appearance.default_font_size must be between 1 and 72")
59 }81 }
60- if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {82+ if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
61- errs = append(errs, "default_ui_scale must be between 0.5 and 3.0")83+ errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
62 }84 }
63 85
64 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)86 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
65 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)87 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
66 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)88 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
67 89
68- if cfg.DefaultSearchEngine == "" {90+ for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
69- errs = append(errs, "default_search_engine cannot be empty")91+ errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
70- } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") {
71- errs = append(errs, "default_search_engine must contain %s placeholder for the search query")
72 }92 }
73 93
74 for key, shortcut := range cfg.SearchShortcuts {94 for key, shortcut := range cfg.SearchShortcuts {
@@ -83,6 +103,16 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
83 }103 }
84 }104 }
85 105
106+ switch cfg.Performance.Profile {
107+ case "", "default", "lite", "balanced", "max", "custom":
108+ // ok
109+ default:
110+ errs = append(errs, fmt.Sprintf(
111+ "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
112+ cfg.Performance.Profile,
113+ ))
114+ }
115+
86 switch cfg.Appearance.ColorScheme {116 switch cfg.Appearance.ColorScheme {
87 case "default", "prefer-dark", "prefer-light":117 case "default", "prefer-dark", "prefer-light":
88 // ok118 // ok
A internal/application/usecase/save_webui_config_test.go +127 -0
@@ -0,0 +1,127 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "math"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ mutate func(*dto.WebUIConfig)
19+ wantErr string
20+ }{
21+ {
22+ name: "rejects invalid default search URL",
23+ mutate: func(cfg *dto.WebUIConfig) {
24+ cfg.DefaultSearchEngine = "not-a-url-%s"
25+ },
26+ wantErr: "default_search_engine",
27+ },
28+ {
29+ name: "rejects invalid performance profile",
30+ mutate: func(cfg *dto.WebUIConfig) {
31+ cfg.Performance.Profile = "turbo"
32+ },
33+ wantErr: "performance.profile",
34+ },
35+ {
36+ name: "rejects NaN UI scale",
37+ mutate: func(cfg *dto.WebUIConfig) {
38+ cfg.DefaultUIScale = math.NaN()
39+ },
40+ wantErr: "default_ui_scale must be a finite value",
41+ },
42+ {
43+ name: "rejects infinite UI scale",
44+ mutate: func(cfg *dto.WebUIConfig) {
45+ cfg.DefaultUIScale = math.Inf(1)
46+ },
47+ wantErr: "default_ui_scale must be a finite value",
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ cfg := validWebUIConfig()
54+ tt.mutate(&cfg)
55+ saver := portmocks.NewMockWebUIConfigSaver(t)
56+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
57+
58+ err := uc.Execute(context.Background(), cfg)
59+
60+ require.Error(t, err)
61+ require.Contains(t, err.Error(), tt.wantErr)
62+ })
63+ }
64+}
65+
66+func TestSaveWebUIConfigUseCase_RejectsDuplicateTrimmedSearchShortcutKeys(t *testing.T) {
67+ saver := portmocks.NewMockWebUIConfigSaver(t)
68+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
69+ cfg := validWebUIConfig()
70+ cfg.SearchShortcuts["ddg"] = dto.SearchShortcut{URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}
71+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: "https://example.com/?q=%s", Description: "Duplicate"}
72+
73+ err := uc.Execute(context.Background(), cfg)
74+
75+ require.Error(t, err)
76+ require.Contains(t, err.Error(), "duplicates")
77+}
78+
79+func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
80+ saver := portmocks.NewMockWebUIConfigSaver(t)
81+ var saved dto.WebUIConfig
82+ saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("dto.WebUIConfig")).Run(func(_ context.Context, cfg dto.WebUIConfig) {
83+ saved = cfg
84+ }).Return(nil).Once()
85+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
86+ cfg := validWebUIConfig()
87+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
88+ cfg.SearchShortcuts[" "] = dto.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
89+
90+ err := uc.Execute(context.Background(), cfg)
91+
92+ require.NoError(t, err)
93+ require.NotContains(t, saved.SearchShortcuts, " ddg ")
94+ require.NotContains(t, saved.SearchShortcuts, "")
95+ require.Contains(t, saved.SearchShortcuts, "ddg")
96+ require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)
97+ require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
98+}
99+
100+func validWebUIConfig() dto.WebUIConfig {
101+ palette := dto.ColorPalette{
102+ Background: "#ffffff",
103+ Surface: "#f8f8f8",
104+ SurfaceVariant: "#eeeeee",
105+ Text: "#111111",
106+ Muted: "#666666",
107+ Accent: "#0055ff",
108+ Border: "#dddddd",
109+ }
110+ return dto.WebUIConfig{
111+ Appearance: dto.WebUIAppearanceConfig{
112+ SansFont: "Inter",
113+ SerifFont: "Georgia",
114+ MonospaceFont: "JetBrains Mono",
115+ DefaultFontSize: 16,
116+ ColorScheme: "prefer-dark",
117+ LightPalette: palette,
118+ DarkPalette: palette,
119+ },
120+ Performance: dto.WebUIPerformanceConfig{
121+ Profile: "default",
122+ },
123+ DefaultUIScale: 1,
124+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
125+ SearchShortcuts: map[string]dto.SearchShortcut{},
126+ }
127+}
M internal/application/usecase/search_history.go +116 -7
@@ -3,17 +3,23 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 "time"7 "time"
7 8
9+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ historydomain "github.com/bnema/dumber/internal/domain/history"
10 "github.com/bnema/dumber/internal/domain/repository"13 "github.com/bnema/dumber/internal/domain/repository"
14+ domainurl "github.com/bnema/dumber/internal/domain/url"
11 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
12 )16 )
13 17
14 // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.18 // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
15 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)19 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
16 20
21+const historyWindowDuration = 24 * time.Hour
22+
17 // SearchHistoryUseCase handles history search and retrieval operations.23 // SearchHistoryUseCase handles history search and retrieval operations.
18 type SearchHistoryUseCase struct {24 type SearchHistoryUseCase struct {
19 historyRepo repository.HistoryRepository25 historyRepo repository.HistoryRepository
@@ -26,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi
26 }32 }
27 }33 }
28 34
29-// SearchInput is an alias for port.HistorySearchInput.35+// SearchInput is an alias for dto.HistorySearchInput.
30-type SearchInput = port.HistorySearchInput36+type SearchInput = dto.HistorySearchInput
31 37
32-// SearchOutput is an alias for port.HistorySearchOutput.38+// SearchOutput is an alias for dto.HistorySearchOutput.
33-type SearchOutput = port.HistorySearchOutput39+type SearchOutput = dto.HistorySearchOutput
34 40
35 // Search performs a full-text search on history entries using SQLite FTS5.41 // Search performs a full-text search on history entries using SQLite FTS5.
36 // Returns only entries that actually match the query terms.42 // Returns only entries that actually match the query terms.
@@ -60,10 +66,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
60 return &SearchOutput{Matches: matches}, nil66 return &SearchOutput{Matches: matches}, nil
61 }67 }
62 68
63-// GetRecent retrieves recent history entries with pagination.69+// GetRecent retrieves recent history entries. A zero limit means all entries;
70+// negative limits retain the historical default page size.
64 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {71 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
65- if limit <= 0 {72+ if limit < 0 {
66- limit = 50 // Default limit73+ limit = 50 // Default limit for invalid negative values.
67 }74 }
68 75
69 entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)76 entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
@@ -74,6 +81,75 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
74 return entries, nil81 return entries, nil
75 }82 }
76 83
84+// GetRecentByDomain retrieves recent history entries for a canonical domain. A
85+// zero limit means all matching entries; negative limits use the default page size.
86+func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
87+ if limit < 0 {
88+ limit = 50
89+ }
90+ domain, err := canonicalHistoryDomain(domain)
91+ if err != nil {
92+ return nil, err
93+ }
94+
95+ entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
98+ }
99+ return entries, nil
100+}
101+
102+// canonicalHistoryDomain normalizes a domain and rejects empty values before
103+// the domain reaches exact-match persistence queries.
104+func canonicalHistoryDomain(domain string) (string, error) {
105+ domain = domainurl.CanonicalDomain(domain)
106+ if domain == "" {
107+ return "", fmt.Errorf("domain is required")
108+ }
109+ return domain, nil
110+}
111+
112+// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.
113+func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
114+ if before.IsZero() {
115+ before = time.Now()
116+ }
117+ before = before.UTC()
118+ after := before.Add(-historyWindowDuration)
119+
120+ var (
121+ entries []*entity.HistoryEntry
122+ hasMore bool
123+ err error
124+ )
125+ originalDomain := domain
126+ domain = domainurl.CanonicalDomain(domain)
127+ if strings.TrimSpace(originalDomain) != "" && domain == "" {
128+ return nil, fmt.Errorf("domain is required")
129+ }
130+ if domain != "" {
131+ entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
132+ if err == nil {
133+ hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
134+ }
135+ } else {
136+ entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)
137+ if err == nil {
138+ hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
139+ }
140+ }
141+ if err != nil {
142+ return nil, fmt.Errorf("failed to get recent history window: %w", err)
143+ }
144+
145+ return &entity.HistoryWindow{
146+ Entries: entries,
147+ Before: before,
148+ After: after,
149+ HasMore: hasMore,
150+ }, nil
151+}
152+
77 // GetRecentSince retrieves history entries visited within the last N days.153 // GetRecentSince retrieves history entries visited within the last N days.
78 // If days is 0, returns all history entries.154 // If days is 0, returns all history entries.
79 // If days is negative, defaults to 30 days.155 // If days is negative, defaults to 30 days.
@@ -154,6 +230,25 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
154 return nil230 return nil
155 }231 }
156 232
233+// ClearRange deletes history entries inside a named recent range.
234+func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
235+ cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
236+ if !ok {
237+ return fmt.Errorf("unknown history delete range: %q", rangeID)
238+ }
239+ if all {
240+ return uc.ClearAll(ctx)
241+ }
242+
243+ log := logging.FromContext(ctx)
244+ log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
245+ if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
246+ return fmt.Errorf("failed to clear history range: %w", err)
247+ }
248+ log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
249+ return nil
250+}
251+
157 // ClearAll deletes all history entries.252 // ClearAll deletes all history entries.
158 func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {253 func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
159 log := logging.FromContext(ctx)254 log := logging.FromContext(ctx)
@@ -182,6 +277,11 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
182 277
183 // DeleteByDomain removes all history entries for a domain.278 // DeleteByDomain removes all history entries for a domain.
184 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {279 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
280+ domain, err := canonicalHistoryDomain(domain)
281+ if err != nil {
282+ return err
283+ }
284+
185 log := logging.FromContext(ctx)285 log := logging.FromContext(ctx)
186 log.Debug().Str("domain", domain).Msg("deleting history by domain")286 log.Debug().Str("domain", domain).Msg("deleting history by domain")
187 287
@@ -205,6 +305,15 @@ func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) (
205 return uc.historyRepo.GetDomainStats(ctx, limit)305 return uc.historyRepo.GetDomainStats(ctx, limit)
206 }306 }
207 307
308+// GetStats retrieves lightweight aggregate history statistics.
309+func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
310+ stats, err := uc.historyRepo.GetStats(ctx)
311+ if err != nil {
312+ return nil, fmt.Errorf("failed to get history stats: %w", err)
313+ }
314+ return stats, nil
315+}
316+
208 // GetAnalytics retrieves aggregated history analytics for the homepage.317 // GetAnalytics retrieves aggregated history analytics for the homepage.
209 func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {318 func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
210 log := logging.FromContext(ctx)319 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +116 -0
@@ -13,6 +13,43 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 )14 )
15 15
16+func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
17+ ctx := testContext()
18+ historyRepo := repomocks.NewMockHistoryRepository(t)
19+ entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}}
20+ historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once()
21+
22+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
23+ result, err := uc.GetRecent(ctx, 0, 0)
24+
25+ require.NoError(t, err)
26+ require.Len(t, result, 1)
27+}
28+
29+func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) {
30+ ctx := testContext()
31+ historyRepo := repomocks.NewMockHistoryRepository(t)
32+ historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once()
33+
34+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
35+ result, err := uc.GetRecent(ctx, -1, 0)
36+
37+ require.NoError(t, err)
38+ assert.Empty(t, result)
39+}
40+
41+func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
42+ ctx := testContext()
43+ historyRepo := repomocks.NewMockHistoryRepository(t)
44+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
45+
46+ result, err := uc.GetRecentWindow(ctx, time.Now(), "///")
47+
48+ require.Error(t, err)
49+ assert.Nil(t, result)
50+ assert.Contains(t, err.Error(), "domain is required")
51+}
52+
16 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {53 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17 ctx := testContext()54 ctx := testContext()
18 55
@@ -151,3 +188,82 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
151 require.NotNil(t, result)188 require.NotNil(t, result)
152 assert.Empty(t, result)189 assert.Empty(t, result)
153 }190 }
191+
192+func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {
193+ ctx := testContext()
194+ historyRepo := repomocks.NewMockHistoryRepository(t)
195+ now := time.Now()
196+ expectedCutoff := now.Add(-time.Hour)
197+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
198+ return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
199+ })).Return(nil).Once()
200+
201+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
202+
203+ err := uc.ClearRange(ctx, "hour")
204+ require.NoError(t, err)
205+}
206+
207+func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
208+ ctx := testContext()
209+ historyRepo := repomocks.NewMockHistoryRepository(t)
210+ now := time.Now()
211+ expectedCutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
212+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
213+ return since.Equal(expectedCutoff)
214+ })).Return(nil).Once()
215+
216+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
217+
218+ err := uc.ClearRange(ctx, "day")
219+ require.NoError(t, err)
220+}
221+
222+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
223+ ctx := testContext()
224+
225+ historyRepo := repomocks.NewMockHistoryRepository(t)
226+
227+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)
228+
229+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
230+
231+ err := uc.ClearRange(ctx, "all")
232+ require.NoError(t, err)
233+}
234+
235+func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {
236+ ctx := testContext()
237+
238+ historyRepo := repomocks.NewMockHistoryRepository(t)
239+
240+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
241+
242+ err := uc.ClearRange(ctx, "bogus")
243+ require.Error(t, err)
244+ assert.Contains(t, err.Error(), "unknown history delete range")
245+}
246+
247+func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
248+ ctx := testContext()
249+ historyRepo := repomocks.NewMockHistoryRepository(t)
250+ want := []*entity.HistoryEntry{{URL: "https://example_.com"}}
251+ historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once()
252+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
253+
254+ result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0)
255+
256+ require.NoError(t, err)
257+ assert.Equal(t, want, result)
258+}
259+
260+func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) {
261+ ctx := testContext()
262+ historyRepo := repomocks.NewMockHistoryRepository(t)
263+ historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once()
264+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
265+
266+ err := uc.DeleteByDomain(ctx, "example_.com")
267+
268+ require.NoError(t, err)
269+}
M internal/bootstrap/engine.go +17 -14
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
10 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"11 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
@@ -33,6 +34,19 @@ type EngineInput struct {
33 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.34 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
34 func BuildEngine(input EngineInput) (port.Engine, error) {35 func BuildEngine(input EngineInput) (port.Engine, error) {
35 cfg := input.Config36 cfg := input.Config
37+ systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())
38+ systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader)
39+ buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {
40+ return func() ([]byte, error) {
41+ payload, err := read(input.Ctx)
42+ if err != nil {
43+ return nil, err
44+ }
45+ return json.Marshal(payload)
46+ }
47+ }
48+ currentConfigPayload := buildConfigPayload(systemviewUC.Current)
49+ defaultConfigPayload := buildConfigPayload(systemviewUC.Default)
36 contextMenuBuilder := usecase.NewBuildContextMenuUseCase()50 contextMenuBuilder := usecase.NewBuildContextMenuUseCase()
37 contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}51 contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}
38 engineType := cfg.Engine.ResolveEngineType()52 engineType := cfg.Engine.ResolveEngineType()
@@ -49,6 +63,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
49 63
50 return webkit.NewEngine(64 return webkit.NewEngine(
51 input.Ctx, cfg, opts, profile, wkCfg,65 input.Ctx, cfg, opts, profile, wkCfg,
66+ currentConfigPayload, defaultConfigPayload,
52 input.ThemeManager, input.ColorResolver,67 input.ThemeManager, input.ColorResolver,
53 contextMenuBuilder, contextMenuExecutorFactory,68 contextMenuBuilder, contextMenuExecutorFactory,
54 input.Logger,69 input.Logger,
@@ -75,23 +90,11 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
75 MaxConcurrent: cfg.Transcoding.MaxConcurrent,90 MaxConcurrent: cfg.Transcoding.MaxConcurrent,
76 Quality: cfg.Transcoding.Quality,91 Quality: cfg.Transcoding.Quality,
77 }92 }
78- surveyor := env.NewHardwareSurveyor()
79- buildConfigPayload := func(cfgf func() *config.Config) func() ([]byte, error) {
80- return func() ([]byte, error) {
81- cfg := cfgf()
82- var hw *port.HardwareInfo
83- if surveyor != nil {
84- survey := surveyor.Survey(context.Background())
85- hw = &survey
86- }
87- return json.Marshal(config.BuildWebUIConfigPayload(cfg, hw))
88- }
89- }
90 deps := cef.EngineDependencies{93 deps := cef.EngineDependencies{
91 RegisterHandlers: handlers.RegisterAll,94 RegisterHandlers: handlers.RegisterAll,
92 RegisterAccentHandlers: handlers.RegisterAccentHandlers,95 RegisterAccentHandlers: handlers.RegisterAccentHandlers,
93- CurrentConfigPayload: buildConfigPayload(config.Get),96+ CurrentConfigPayload: currentConfigPayload,
94- DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),97+ DefaultConfigPayload: defaultConfigPayload,
95 ContextMenuBuilder: contextMenuBuilder,98 ContextMenuBuilder: contextMenuBuilder,
96 ContextMenuExecutorFactory: contextMenuExecutorFactory,99 ContextMenuExecutorFactory: contextMenuExecutorFactory,
97 Clipboard: clipboardinfra.New(),100 Clipboard: clipboardinfra.New(),
A internal/domain/entity/history_window.go +11 -0
@@ -0,0 +1,11 @@
1+package entity
2+
3+import "time"
4+
5+// HistoryWindow contains one fixed-duration slice of browsing history.
6+type HistoryWindow struct {
7+ Entries []*HistoryEntry `json:"entries"`
8+ Before time.Time `json:"before"`
9+ After time.Time `json:"after"`
10+ HasMore bool `json:"has_more"`
11+}
A internal/domain/history/delete_range.go +23 -0
@@ -0,0 +1,23 @@
1+package history
2+
3+import "time"
4+
5+// DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.
6+// The booleans report whether the range means "delete all" and whether the
7+// identifier was recognized.
8+func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) {
9+ switch rangeID {
10+ case "hour":
11+ return now.Add(-time.Hour), false, true
12+ case "day":
13+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), false, true
14+ case "week":
15+ return now.AddDate(0, 0, -7), false, true
16+ case "month":
17+ return now.AddDate(0, -1, 0), false, true
18+ case "all":
19+ return time.Time{}, true, true
20+ default:
21+ return time.Time{}, false, false
22+ }
23+}
A internal/domain/history/delete_range_test.go +40 -0
@@ -0,0 +1,40 @@
1+package history
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestDeleteRangeCutoff(t *testing.T) {
9+ now := time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC)
10+
11+ tests := []struct {
12+ name string
13+ rangeID string
14+ want time.Time
15+ wantAll bool
16+ wantOK bool
17+ }{
18+ {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true},
19+ {name: "day", rangeID: "day", want: time.Date(2026, time.April, 11, 0, 0, 0, 0, time.UTC), wantAll: false, wantOK: true},
20+ {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},
21+ {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},
22+ {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true},
23+ {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now)
29+ if !got.Equal(tt.want) {
30+ t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)
31+ }
32+ if gotAll != tt.wantAll {
33+ t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)
34+ }
35+ if gotOK != tt.wantOK {
36+ t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK)
37+ }
38+ })
39+ }
40+}
M internal/domain/repository/history.go +19 -1
@@ -18,9 +18,24 @@ type HistoryRepository interface {
18 // Search performs a fuzzy search on history entries.18 // Search performs a fuzzy search on history entries.
19 Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)19 Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)
20 20
21- // GetRecent retrieves recent history entries with pagination.21+ // GetRecent retrieves recent history entries with pagination. A zero limit means all entries.
22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
23 23
24+ // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
25+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
26+
27+ // GetRecentWindow retrieves entries visited in [after, before), sorted by recency.
28+ GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error)
29+
30+ // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency.
31+ GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error)
32+
33+ // HasEntriesBefore reports whether any history entry was visited before the given time.
34+ HasEntriesBefore(ctx context.Context, before time.Time) (bool, error)
35+
36+ // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time.
37+ HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error)
38+
24 // GetRecentSince retrieves history entries visited within the last N days.39 // GetRecentSince retrieves history entries visited within the last N days.
25 // days must be > 0.40 // days must be > 0.
26 GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)41 GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
@@ -44,6 +59,9 @@ type HistoryRepository interface {
44 // DeleteOlderThan removes entries older than the given time.59 // DeleteOlderThan removes entries older than the given time.
45 DeleteOlderThan(ctx context.Context, before time.Time) error60 DeleteOlderThan(ctx context.Context, before time.Time) error
46 61
62+ // DeleteSince removes entries visited at or after the given time.
63+ DeleteSince(ctx context.Context, since time.Time) error
64+
47 // DeleteAll removes all history entries.65 // DeleteAll removes all history entries.
48 DeleteAll(ctx context.Context) error66 DeleteAll(ctx context.Context) error
49 67
M internal/domain/repository/mocks/mocks.go +429 -0
@@ -2129,6 +2129,63 @@ func (_c *MockHistoryRepository_DeleteOlderThan_Call) RunAndReturn(run func(ctx
2129 return _c2129 return _c
2130 }2130 }
2131 2131
2132+// DeleteSince provides a mock function for the type MockHistoryRepository
2133+func (_mock *MockHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error {
2134+ ret := _mock.Called(ctx, since)
2135+
2136+ if len(ret) == 0 {
2137+ panic("no return value specified for DeleteSince")
2138+ }
2139+
2140+ var r0 error
2141+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {
2142+ r0 = returnFunc(ctx, since)
2143+ } else {
2144+ r0 = ret.Error(0)
2145+ }
2146+ return r0
2147+}
2148+
2149+// MockHistoryRepository_DeleteSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSince'
2150+type MockHistoryRepository_DeleteSince_Call struct {
2151+ *mock.Call
2152+}
2153+
2154+// DeleteSince is a helper method to define mock.On call
2155+// - ctx context.Context
2156+// - since time.Time
2157+func (_e *MockHistoryRepository_Expecter) DeleteSince(ctx interface{}, since interface{}) *MockHistoryRepository_DeleteSince_Call {
2158+ return &MockHistoryRepository_DeleteSince_Call{Call: _e.mock.On("DeleteSince", ctx, since)}
2159+}
2160+
2161+func (_c *MockHistoryRepository_DeleteSince_Call) Run(run func(ctx context.Context, since time.Time)) *MockHistoryRepository_DeleteSince_Call {
2162+ _c.Call.Run(func(args mock.Arguments) {
2163+ var arg0 context.Context
2164+ if args[0] != nil {
2165+ arg0 = args[0].(context.Context)
2166+ }
2167+ var arg1 time.Time
2168+ if args[1] != nil {
2169+ arg1 = args[1].(time.Time)
2170+ }
2171+ run(
2172+ arg0,
2173+ arg1,
2174+ )
2175+ })
2176+ return _c
2177+}
2178+
2179+func (_c *MockHistoryRepository_DeleteSince_Call) Return(err error) *MockHistoryRepository_DeleteSince_Call {
2180+ _c.Call.Return(err)
2181+ return _c
2182+}
2183+
2184+func (_c *MockHistoryRepository_DeleteSince_Call) RunAndReturn(run func(ctx context.Context, since time.Time) error) *MockHistoryRepository_DeleteSince_Call {
2185+ _c.Call.Return(run)
2186+ return _c
2187+}
2188+
2132 // FindByURL provides a mock function for the type MockHistoryRepository2189 // FindByURL provides a mock function for the type MockHistoryRepository
2133 func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {2190 func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
2134 ret := _mock.Called(ctx, url)2191 ret := _mock.Called(ctx, url)
@@ -2661,6 +2718,86 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
2661 return _c2718 return _c
2662 }2719 }
2663 2720
2721+// GetRecentByDomain provides a mock function for the type MockHistoryRepository
2722+func (_mock *MockHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
2723+ ret := _mock.Called(ctx, domain, limit, offset)
2724+
2725+ if len(ret) == 0 {
2726+ panic("no return value specified for GetRecentByDomain")
2727+ }
2728+
2729+ var r0 []*entity.HistoryEntry
2730+ var r1 error
2731+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
2732+ return returnFunc(ctx, domain, limit, offset)
2733+ }
2734+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
2735+ r0 = returnFunc(ctx, domain, limit, offset)
2736+ } else {
2737+ if ret.Get(0) != nil {
2738+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2739+ }
2740+ }
2741+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
2742+ r1 = returnFunc(ctx, domain, limit, offset)
2743+ } else {
2744+ r1 = ret.Error(1)
2745+ }
2746+ return r0, r1
2747+}
2748+
2749+// MockHistoryRepository_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
2750+type MockHistoryRepository_GetRecentByDomain_Call struct {
2751+ *mock.Call
2752+}
2753+
2754+// GetRecentByDomain is a helper method to define mock.On call
2755+// - ctx context.Context
2756+// - domain string
2757+// - limit int
2758+// - offset int
2759+func (_e *MockHistoryRepository_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHistoryRepository_GetRecentByDomain_Call {
2760+ return &MockHistoryRepository_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
2761+}
2762+
2763+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHistoryRepository_GetRecentByDomain_Call {
2764+ _c.Call.Run(func(args mock.Arguments) {
2765+ var arg0 context.Context
2766+ if args[0] != nil {
2767+ arg0 = args[0].(context.Context)
2768+ }
2769+ var arg1 string
2770+ if args[1] != nil {
2771+ arg1 = args[1].(string)
2772+ }
2773+ var arg2 int
2774+ if args[2] != nil {
2775+ arg2 = args[2].(int)
2776+ }
2777+ var arg3 int
2778+ if args[3] != nil {
2779+ arg3 = args[3].(int)
2780+ }
2781+ run(
2782+ arg0,
2783+ arg1,
2784+ arg2,
2785+ arg3,
2786+ )
2787+ })
2788+ return _c
2789+}
2790+
2791+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentByDomain_Call {
2792+ _c.Call.Return(historyEntrys, err)
2793+ return _c
2794+}
2795+
2796+func (_c *MockHistoryRepository_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentByDomain_Call {
2797+ _c.Call.Return(run)
2798+ return _c
2799+}
2800+
2664 // GetRecentSince provides a mock function for the type MockHistoryRepository2801 // GetRecentSince provides a mock function for the type MockHistoryRepository
2665 func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {2802 func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2666 ret := _mock.Called(ctx, days)2803 ret := _mock.Called(ctx, days)
@@ -2729,6 +2866,160 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c
2729 return _c2866 return _c
2730 }2867 }
2731 2868
2869+// GetRecentWindow provides a mock function for the type MockHistoryRepository
2870+func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
2871+ ret := _mock.Called(ctx, before, after)
2872+
2873+ if len(ret) == 0 {
2874+ panic("no return value specified for GetRecentWindow")
2875+ }
2876+
2877+ var r0 []*entity.HistoryEntry
2878+ var r1 error
2879+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
2880+ return returnFunc(ctx, before, after)
2881+ }
2882+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok {
2883+ r0 = returnFunc(ctx, before, after)
2884+ } else {
2885+ if ret.Get(0) != nil {
2886+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2887+ }
2888+ }
2889+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok {
2890+ r1 = returnFunc(ctx, before, after)
2891+ } else {
2892+ r1 = ret.Error(1)
2893+ }
2894+ return r0, r1
2895+}
2896+
2897+// MockHistoryRepository_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow'
2898+type MockHistoryRepository_GetRecentWindow_Call struct {
2899+ *mock.Call
2900+}
2901+
2902+// GetRecentWindow is a helper method to define mock.On call
2903+// - ctx context.Context
2904+// - before time.Time
2905+// - after time.Time
2906+func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindow_Call {
2907+ return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)}
2908+}
2909+
2910+func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call {
2911+ _c.Call.Run(func(args mock.Arguments) {
2912+ var arg0 context.Context
2913+ if args[0] != nil {
2914+ arg0 = args[0].(context.Context)
2915+ }
2916+ var arg1 time.Time
2917+ if args[1] != nil {
2918+ arg1 = args[1].(time.Time)
2919+ }
2920+ var arg2 time.Time
2921+ if args[2] != nil {
2922+ arg2 = args[2].(time.Time)
2923+ }
2924+ run(
2925+ arg0,
2926+ arg1,
2927+ arg2,
2928+ )
2929+ })
2930+ return _c
2931+}
2932+
2933+func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindow_Call {
2934+ _c.Call.Return(historyEntrys, err)
2935+ return _c
2936+}
2937+
2938+func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call {
2939+ _c.Call.Return(run)
2940+ return _c
2941+}
2942+
2943+// GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository
2944+func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
2945+ ret := _mock.Called(ctx, domain, before, after)
2946+
2947+ if len(ret) == 0 {
2948+ panic("no return value specified for GetRecentWindowByDomain")
2949+ }
2950+
2951+ var r0 []*entity.HistoryEntry
2952+ var r1 error
2953+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
2954+ return returnFunc(ctx, domain, before, after)
2955+ }
2956+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok {
2957+ r0 = returnFunc(ctx, domain, before, after)
2958+ } else {
2959+ if ret.Get(0) != nil {
2960+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2961+ }
2962+ }
2963+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok {
2964+ r1 = returnFunc(ctx, domain, before, after)
2965+ } else {
2966+ r1 = ret.Error(1)
2967+ }
2968+ return r0, r1
2969+}
2970+
2971+// MockHistoryRepository_GetRecentWindowByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindowByDomain'
2972+type MockHistoryRepository_GetRecentWindowByDomain_Call struct {
2973+ *mock.Call
2974+}
2975+
2976+// GetRecentWindowByDomain is a helper method to define mock.On call
2977+// - ctx context.Context
2978+// - domain string
2979+// - before time.Time
2980+// - after time.Time
2981+func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx interface{}, domain interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindowByDomain_Call {
2982+ return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)}
2983+}
2984+
2985+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
2986+ _c.Call.Run(func(args mock.Arguments) {
2987+ var arg0 context.Context
2988+ if args[0] != nil {
2989+ arg0 = args[0].(context.Context)
2990+ }
2991+ var arg1 string
2992+ if args[1] != nil {
2993+ arg1 = args[1].(string)
2994+ }
2995+ var arg2 time.Time
2996+ if args[2] != nil {
2997+ arg2 = args[2].(time.Time)
2998+ }
2999+ var arg3 time.Time
3000+ if args[3] != nil {
3001+ arg3 = args[3].(time.Time)
3002+ }
3003+ run(
3004+ arg0,
3005+ arg1,
3006+ arg2,
3007+ arg3,
3008+ )
3009+ })
3010+ return _c
3011+}
3012+
3013+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindowByDomain_Call {
3014+ _c.Call.Return(historyEntrys, err)
3015+ return _c
3016+}
3017+
3018+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
3019+ _c.Call.Return(run)
3020+ return _c
3021+}
3022+
2732 // GetStats provides a mock function for the type MockHistoryRepository3023 // GetStats provides a mock function for the type MockHistoryRepository
2733 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {3024 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
2734 ret := _mock.Called(ctx)3025 ret := _mock.Called(ctx)
@@ -2791,6 +3082,144 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context
2791 return _c3082 return _c
2792 }3083 }
2793 3084
3085+// HasEntriesBefore provides a mock function for the type MockHistoryRepository
3086+func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
3087+ ret := _mock.Called(ctx, before)
3088+
3089+ if len(ret) == 0 {
3090+ panic("no return value specified for HasEntriesBefore")
3091+ }
3092+
3093+ var r0 bool
3094+ var r1 error
3095+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok {
3096+ return returnFunc(ctx, before)
3097+ }
3098+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok {
3099+ r0 = returnFunc(ctx, before)
3100+ } else {
3101+ r0 = ret.Get(0).(bool)
3102+ }
3103+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
3104+ r1 = returnFunc(ctx, before)
3105+ } else {
3106+ r1 = ret.Error(1)
3107+ }
3108+ return r0, r1
3109+}
3110+
3111+// MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore'
3112+type MockHistoryRepository_HasEntriesBefore_Call struct {
3113+ *mock.Call
3114+}
3115+
3116+// HasEntriesBefore is a helper method to define mock.On call
3117+// - ctx context.Context
3118+// - before time.Time
3119+func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx interface{}, before interface{}) *MockHistoryRepository_HasEntriesBefore_Call {
3120+ return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)}
3121+}
3122+
3123+func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call {
3124+ _c.Call.Run(func(args mock.Arguments) {
3125+ var arg0 context.Context
3126+ if args[0] != nil {
3127+ arg0 = args[0].(context.Context)
3128+ }
3129+ var arg1 time.Time
3130+ if args[1] != nil {
3131+ arg1 = args[1].(time.Time)
3132+ }
3133+ run(
3134+ arg0,
3135+ arg1,
3136+ )
3137+ })
3138+ return _c
3139+}
3140+
3141+func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call {
3142+ _c.Call.Return(b, err)
3143+ return _c
3144+}
3145+
3146+func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call {
3147+ _c.Call.Return(run)
3148+ return _c
3149+}
3150+
3151+// HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository
3152+func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
3153+ ret := _mock.Called(ctx, domain, before)
3154+
3155+ if len(ret) == 0 {
3156+ panic("no return value specified for HasEntriesByDomainBefore")
3157+ }
3158+
3159+ var r0 bool
3160+ var r1 error
3161+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok {
3162+ return returnFunc(ctx, domain, before)
3163+ }
3164+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok {
3165+ r0 = returnFunc(ctx, domain, before)
3166+ } else {
3167+ r0 = ret.Get(0).(bool)
3168+ }
3169+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok {
3170+ r1 = returnFunc(ctx, domain, before)
3171+ } else {
3172+ r1 = ret.Error(1)
3173+ }
3174+ return r0, r1
3175+}
3176+
3177+// MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore'
3178+type MockHistoryRepository_HasEntriesByDomainBefore_Call struct {
3179+ *mock.Call
3180+}
3181+
3182+// HasEntriesByDomainBefore is a helper method to define mock.On call
3183+// - ctx context.Context
3184+// - domain string
3185+// - before time.Time
3186+func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx interface{}, domain interface{}, before interface{}) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3187+ return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)}
3188+}
3189+
3190+func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3191+ _c.Call.Run(func(args mock.Arguments) {
3192+ var arg0 context.Context
3193+ if args[0] != nil {
3194+ arg0 = args[0].(context.Context)
3195+ }
3196+ var arg1 string
3197+ if args[1] != nil {
3198+ arg1 = args[1].(string)
3199+ }
3200+ var arg2 time.Time
3201+ if args[2] != nil {
3202+ arg2 = args[2].(time.Time)
3203+ }
3204+ run(
3205+ arg0,
3206+ arg1,
3207+ arg2,
3208+ )
3209+ })
3210+ return _c
3211+}
3212+
3213+func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3214+ _c.Call.Return(b, err)
3215+ return _c
3216+}
3217+
3218+func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3219+ _c.Call.Return(run)
3220+ return _c
3221+}
3222+
2794 // IncrementVisitCount provides a mock function for the type MockHistoryRepository3223 // IncrementVisitCount provides a mock function for the type MockHistoryRepository
2795 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {3224 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
2796 ret := _mock.Called(ctx, url)3225 ret := _mock.Called(ctx, url)
M internal/domain/url/normalize.go +27 -1
@@ -178,7 +178,33 @@ func ExtractDomain(rawURL string) string {
178 if err != nil || parsed.Host == "" {178 if err != nil || parsed.Host == "" {
179 return ""179 return ""
180 }180 }
181- return strings.TrimPrefix(parsed.Host, "www.")181+ return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
182+}
183+
184+// CanonicalDomain extracts a normalized domain from either a URL string or a
185+// host-like domain string. It keeps non-default ports so callers that need to
186+// round-trip repository domain keys do not lose identity.
187+func CanonicalDomain(raw string) string {
188+ raw = strings.TrimSpace(strings.ToLower(raw))
189+ if raw == "" {
190+ return ""
191+ }
192+ if domain := ExtractDomain(raw); domain != "" {
193+ return domain
194+ }
195+ if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
196+ raw = raw[:idx]
197+ }
198+ return strings.TrimPrefix(raw, "www.")
199+}
200+
201+// DisplayDomain returns a user-facing domain label with any port removed.
202+func DisplayDomain(raw string) string {
203+ domain := CanonicalDomain(raw)
204+ if host, _, err := net.SplitHostPort(domain); err == nil {
205+ return host
206+ }
207+ return domain
182 }208 }
183 209
184 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.210 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
M internal/domain/url/normalize_test.go +57 -4
@@ -34,8 +34,8 @@ func TestNormalize(t *testing.T) {
34 },34 },
35 {35 {
36 name: "dumb scheme unchanged",36 name: "dumb scheme unchanged",
37- input: "dumb://home",37+ input: "dumb://history",
38- want: "dumb://home",38+ want: "dumb://history",
39 },39 },
40 {40 {
41 name: "about scheme unchanged",41 name: "about scheme unchanged",
@@ -347,7 +347,7 @@ func TestLooksLikeURL(t *testing.T) {
347 },347 },
348 {348 {
349 name: "dumb URL",349 name: "dumb URL",
350- input: "dumb://home",350+ input: "dumb://history",
351 want: true,351 want: true,
352 },352 },
353 {353 {
@@ -506,7 +506,7 @@ func TestIsExternalScheme(t *testing.T) {
506 },506 },
507 {507 {
508 name: "dumb scheme",508 name: "dumb scheme",
509- input: "dumb://home",509+ input: "dumb://history",
510 want: false,510 want: false,
511 },511 },
512 {512 {
@@ -692,3 +692,56 @@ func TestExtractOrigin_InvalidURI(t *testing.T) {
692 })692 })
693 }693 }
694 }694 }
695+
696+func TestCanonicalDomain(t *testing.T) {
697+ tests := []struct {
698+ name string
699+ input string
700+ want string
701+ }{
702+ {name: "empty", input: "", want: ""},
703+ {name: "whitespace only", input: " ", want: ""},
704+ {name: "url", input: "https://www.example.com/path", want: "example.com"},
705+ {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
706+ {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
707+ {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"},
708+ {name: "domain", input: "www.example.com", want: "example.com"},
709+ {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
710+ }
711+
712+ for _, tt := range tests {
713+ t.Run(tt.name, func(t *testing.T) {
714+ if got := CanonicalDomain(tt.input); got != tt.want {
715+ t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
716+ }
717+ })
718+ }
719+}
720+
721+func TestDisplayDomain(t *testing.T) {
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+ })
746+ }
747+}
A internal/infrastructure/cef/favicon_systemviews.go +15 -0
@@ -0,0 +1,15 @@
1+package cef
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)
6+
7+// SetSystemviewFaviconService exposes the local favicon cache to internal
8+// systemview pages. The scheme handler only serves cached PNGs; it does not
9+// fetch remote favicons on behalf of History rendering.
10+func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {
11+ if e == nil || e.schemeHandler == nil {
12+ return
13+ }
14+ e.schemeHandler.setFaviconService(service)
15+}
M internal/infrastructure/cef/internal_urls.go +4 -4
@@ -41,7 +41,7 @@ func toActualInternalURL(raw string) string {
41 if page == "" {41 if page == "" {
42 page = parsed.Opaque42 page = parsed.Opaque
43 }43 }
44- if page == "" {44+ if page == "" || !isInternalPageHost(page) {
45 return raw45 return raw
46 }46 }
47 47
@@ -49,8 +49,8 @@ func toActualInternalURL(raw string) string {
49 trimmedPath := strings.Trim(parsed.Path, "/")49 trimmedPath := strings.Trim(parsed.Path, "/")
50 switch {50 switch {
51 case trimmedPath == "":51 case trimmedPath == "":
52- // Keep page roots at /home instead of /home/ so relative assets resolve52+ // Keep page roots at /history instead of /history/ so relative assets
53- // from the origin root.53+ // resolve from the origin root.
54 case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):54 case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):
55 actualPath = "/" + trimmedPath55 actualPath = "/" + trimmedPath
56 case path.Ext(trimmedPath) != "":56 case path.Ext(trimmedPath) != "":
@@ -134,7 +134,7 @@ func toConceptualInternalURL(raw string) string {
134 134
135 func isInternalPageHost(host string) bool {135 func isInternalPageHost(host string) bool {
136 switch host {136 switch host {
137- case homePath, configPath, webrtcPath, errorPath:137+ case historyPath, favoritesPath, configPath, errorPath:
138 return true138 return true
139 default:139 default:
140 return false140 return false
M internal/infrastructure/cef/internal_urls_test.go +53 -12
@@ -15,23 +15,43 @@ func TestToActualInternalURL(t *testing.T) {
15 }{15 }{
16 {16 {
17 name: "page root",17 name: "page root",
18- in: "dumb://home",18+ in: "dumb://history",
19- want: "https://dumber.invalid/home",19+ want: "https://dumber.invalid/history",
20+ },
21+ {
22+ name: "error page root",
23+ in: "dumb://error",
24+ want: "https://dumber.invalid/error",
25+ },
26+ {
27+ name: "favorites page root",
28+ in: "dumb://favorites",
29+ want: "https://dumber.invalid/favorites",
30+ },
31+ {
32+ name: "config page root",
33+ in: "dumb://config",
34+ want: "https://dumber.invalid/config",
20 },35 },
21 {36 {
22 name: "api path stays at origin root",37 name: "api path stays at origin root",
23- in: "dumb://home/api/message",38+ in: "dumb://history/api/message",
24 want: "https://dumber.invalid/api/message",39 want: "https://dumber.invalid/api/message",
25 },40 },
41+ {
42+ name: "config api path stays at origin root",
43+ in: "dumb://config/api/config/default",
44+ want: "https://dumber.invalid/api/config/default",
45+ },
26 {46 {
27 name: "root asset stays at origin root",47 name: "root asset stays at origin root",
28- in: "dumb://home/favicon.ico",48+ in: "dumb://history/favicon.ico",
29 want: "https://dumber.invalid/favicon.ico",49 want: "https://dumber.invalid/favicon.ico",
30 },50 },
31 {51 {
32 name: "page subroute stays under page namespace",52 name: "page subroute stays under page namespace",
33- in: "dumb://home/crash?url=https%3A%2F%2Fexample.com",53+ in: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
34- want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",54+ want: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com",
35 },55 },
36 {56 {
37 name: "actual internal URL unchanged",57 name: "actual internal URL unchanged",
@@ -72,11 +92,17 @@ func TestResolveAPIPath(t *testing.T) {
72 ok: true,92 ok: true,
73 },93 },
74 {94 {
75- name: "conceptual page api path",95+ name: "conceptual history api path",
76- in: "dumb://home/api/message",96+ in: "dumb://history/api/message",
77 want: "/api/message",97 want: "/api/message",
78 ok: true,98 ok: true,
79 },99 },
100+ {
101+ name: "conceptual config api path",
102+ in: "dumb://config/api/config/default",
103+ want: "/api/config/default",
104+ ok: true,
105+ },
80 {106 {
81 name: "actual internal api path",107 name: "actual internal api path",
82 in: "https://dumber.invalid/api/focus-sync",108 in: "https://dumber.invalid/api/focus-sync",
@@ -120,13 +146,28 @@ func TestToConceptualInternalURL(t *testing.T) {
120 }{146 }{
121 {147 {
122 name: "page root",148 name: "page root",
123- in: "https://dumber.invalid/home",149+ in: "https://dumber.invalid/history",
124- want: "dumb://home",150+ want: "dumb://history",
151+ },
152+ {
153+ name: "error page root",
154+ in: "https://dumber.invalid/error",
155+ want: "dumb://error",
156+ },
157+ {
158+ name: "favorites page root",
159+ in: "https://dumber.invalid/favorites",
160+ want: "dumb://favorites",
161+ },
162+ {
163+ name: "config page root",
164+ in: "https://dumber.invalid/config",
165+ want: "dumb://config",
125 },166 },
126 {167 {
127 name: "page subroute",168 name: "page subroute",
128- in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",169+ in: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com",
129- want: "dumb://home/crash?url=https%3A%2F%2Fexample.com",170+ want: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
130 },171 },
131 {172 {
132 name: "root asset is left unchanged",173 name: "root asset is left unchanged",
M internal/infrastructure/cef/popup_opener_bridge_test.go +1 -1
@@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
37 }37 }
38 38
39 func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {39 func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
40- require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))40+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://history"))
41 }41 }
42 42
43 func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {43 func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
M internal/infrastructure/cef/scheme_handler.go +505 -56
@@ -1,24 +1,32 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "embed"6 "embed"
6 "encoding/base64"7 "encoding/base64"
7 "encoding/json"8 "encoding/json"
8 "fmt"9 "fmt"
9 "html"10 "html"
11+ "io"
10 "io/fs"12 "io/fs"
13+ "net"
11 "net/http"14 "net/http"
12 "net/url"15 "net/url"
13- "path/filepath"16+ "os"
17+ "path"
18+ "strconv"
14 "strings"19 "strings"
15 "sync"20 "sync"
16 "time"21 "time"
17 "unsafe"22 "unsafe"
18 23
24+ "github.com/andybalholm/brotli"
19 purecef "github.com/bnema/purego-cef/cef"25 purecef "github.com/bnema/purego-cef/cef"
20 26
21 "github.com/bnema/dumber/internal/application/port"27 "github.com/bnema/dumber/internal/application/port"
28+ domainurl "github.com/bnema/dumber/internal/domain/url"
29+ "github.com/bnema/dumber/internal/infrastructure/netguard"
22 "github.com/bnema/dumber/internal/infrastructure/webutil"30 "github.com/bnema/dumber/internal/infrastructure/webutil"
23 "github.com/bnema/dumber/internal/logging"31 "github.com/bnema/dumber/internal/logging"
24 "github.com/rs/zerolog"32 "github.com/rs/zerolog"
@@ -26,20 +34,22 @@ import (
26 34
27 // Scheme path constants matching WebKit's naming.35 // Scheme path constants matching WebKit's naming.
28 const (36 const (
29- homePath = "home"37+ historyPath = "history"
38+ favoritesPath = "favorites"
30 configPath = "config"39 configPath = "config"
31- webrtcPath = "webrtc"
32 errorPath = "error"40 errorPath = "error"
33 indexHTML = "index.html"41 indexHTML = "index.html"
34 maxSchemeTruncatedURLLength = 24042 maxSchemeTruncatedURLLength = 240
43+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
44+ systemviewsAssetDir = "systemviews"
35 )45 )
36 46
37 // pageRootFiles maps internal page hosts/paths to their HTML entry points.47 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
38 var pageRootFiles = map[string]string{48 var pageRootFiles = map[string]string{
39- homePath: indexHTML,49+ historyPath: indexHTML,
40- configPath: "config.html",50+ favoritesPath: indexHTML,
41- webrtcPath: "webrtc.html",51+ configPath: indexHTML,
42- errorPath: "error.html",52+ errorPath: indexHTML,
43 }53 }
44 54
45 var cefNewResourceHandler = purecef.NewResourceHandler55 var cefNewResourceHandler = purecef.NewResourceHandler
@@ -50,6 +60,7 @@ type dumbSchemeHandler struct {
50 ctx context.Context60 ctx context.Context
51 messageRouter *MessageRouter61 messageRouter *MessageRouter
52 transcoder port.MediaTranscoder62 transcoder port.MediaTranscoder
63+ faviconService port.FaviconService
53 assets embed.FS64 assets embed.FS
54 assetsSet bool65 assetsSet bool
55 assetDir string66 assetDir string
@@ -100,14 +111,14 @@ func newDumbSchemeHandler(
100 ctx: ctx,111 ctx: ctx,
101 messageRouter: router,112 messageRouter: router,
102 transcoder: transcoder,113 transcoder: transcoder,
103- assetDir: "webui",114+ assetDir: systemviewsAssetDir,
104 logger: log.With().Str("component", "scheme-handler").Logger(),115 logger: log.With().Str("component", "scheme-handler").Logger(),
105 currentConfigPayload: currentConfigPayload,116 currentConfigPayload: currentConfigPayload,
106 defaultConfigPayload: defaultConfigPayload,117 defaultConfigPayload: defaultConfigPayload,
107 }, nil118 }, nil
108 }119 }
109 120
110-// setAssets sets the embedded filesystem containing webui assets.121+// setAssets sets the embedded filesystem containing systemviews assets.
111 func (h *dumbSchemeHandler) setAssets(assets embed.FS) {122 func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
112 h.mu.Lock()123 h.mu.Lock()
113 defer h.mu.Unlock()124 defer h.mu.Unlock()
@@ -116,6 +127,12 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
116 h.logger.Debug().Msg("assets filesystem configured")127 h.logger.Debug().Msg("assets filesystem configured")
117 }128 }
118 129
130+func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) {
131+ h.mu.Lock()
132+ defer h.mu.Unlock()
133+ h.faviconService = service
134+}
135+
119 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread136 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
120 // for requests to either dumb:// or the internal https origin.137 // for requests to either dumb:// or the internal https origin.
121 func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {138 func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
@@ -162,17 +179,28 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
162 }179 }
163 180
164 // handleAPI routes API requests to the message router or built-in handlers.181 // handleAPI routes API requests to the message router or built-in handlers.
165-func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {182+func (h *dumbSchemeHandler) handleAPI(
183+ browser purecef.Browser,
184+ method string,
185+ requestPath string,
186+ request purecef.Request,
187+) purecef.ResourceHandler {
166 if strings.EqualFold(method, http.MethodOptions) {188 if strings.EqualFold(method, http.MethodOptions) {
189+ if requestPath == "/api/config" || requestPath == "/api/config/default" {
190+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
191+ return denied
192+ }
193+ return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
194+ }
167 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)195 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
168 }196 }
169 if isAPIGetMethod(method) {197 if isAPIGetMethod(method) {
170- if handler, ok := h.handleAPIGet(path, request); ok {198+ if handler, ok := h.handleAPIGet(requestPath, request); ok {
171 return handler199 return handler
172 }200 }
173 }201 }
174 if isAPIPostMethod(method) {202 if isAPIPostMethod(method) {
175- if handler, ok := h.handleAPIPost(browser, path, request); ok {203+ if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
176 return handler204 return handler
177 }205 }
178 }206 }
@@ -187,21 +215,33 @@ func isAPIPostMethod(method string) bool {
187 return strings.EqualFold(method, http.MethodPost)215 return strings.EqualFold(method, http.MethodPost)
188 }216 }
189 217
190-func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {218+func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
191- switch path {219+ switch requestPath {
192 case "/api/config":220 case "/api/config":
221+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
222+ return denied, true
223+ }
193 return h.handleConfigAPI(h.currentConfigPayload), true224 return h.handleConfigAPI(h.currentConfigPayload), true
194 case "/api/config/default":225 case "/api/config/default":
226+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
227+ return denied, true
228+ }
195 return h.handleConfigAPI(h.defaultConfigPayload), true229 return h.handleConfigAPI(h.defaultConfigPayload), true
196 case "/api/transcode":230 case "/api/transcode":
197 return h.handleTranscodeAPI(request), true231 return h.handleTranscodeAPI(request), true
232+ case "/api/favicon":
233+ return h.handleFaviconAPI(request), true
198 default:234 default:
199 return nil, false235 return nil, false
200 }236 }
201 }237 }
202 238
203-func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) {239+func (h *dumbSchemeHandler) handleAPIPost(
204- switch path {240+ browser purecef.Browser,
241+ requestPath string,
242+ request purecef.Request,
243+) (purecef.ResourceHandler, bool) {
244+ switch requestPath {
205 case "/api/message":245 case "/api/message":
206 return h.handleMessageAPI(request), true246 return h.handleMessageAPI(request), true
207 case "/api/clipboard-set":247 case "/api/clipboard-set":
@@ -224,6 +264,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
224 }264 }
225 265
226 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {266 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
267+ if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
268+ return denied
269+ }
227 body := readBodyFromHeader(request)270 body := readBodyFromHeader(request)
228 if body == nil {271 if body == nil {
229 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})272 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
@@ -235,6 +278,98 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re
235 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)278 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
236 }279 }
237 280
281+func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler {
282+ origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
283+ if origin == "" || strings.EqualFold(origin, actualInternalOrigin) {
284+ return nil
285+ }
286+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
287+}
288+
289+func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
290+ return h.rejectUntrustedSystemviewRequester(request)
291+}
292+
293+func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
294+ if request == nil {
295+ return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
296+ }
297+ origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
298+ if origin != "" {
299+ if isTrustedSystemviewURL(origin) {
300+ return nil
301+ }
302+ return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
303+ }
304+ referrer := strings.TrimSpace(request.GetReferrerURL())
305+ if referrer == "" {
306+ referrer = strings.TrimSpace(request.GetHeaderByName("Referer"))
307+ }
308+ if !isTrustedSystemviewURL(referrer) {
309+ return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
310+ }
311+ return nil
312+}
313+
314+const systemviewFaviconSize = 32
315+
316+func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler {
317+ if denied := h.rejectUntrustedFaviconRequester(request); denied != nil {
318+ return denied
319+ }
320+
321+ h.mu.RLock()
322+ service := h.faviconService
323+ h.mu.RUnlock()
324+ if service == nil {
325+ return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
326+ }
327+
328+ parsed, err := url.Parse(request.GetURL())
329+ if err != nil {
330+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
331+ }
332+ domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
333+ if domain == "" {
334+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})
335+ }
336+ size := systemviewFaviconSize
337+ if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
338+ parsedSize, parseErr := strconv.Atoi(rawSize)
339+ if parseErr != nil || parsedSize != systemviewFaviconSize {
340+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"})
341+ }
342+ size = parsedSize
343+ }
344+
345+ return newFaviconResourceHandler(service, domain, size)
346+}
347+
348+func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
349+ return h.rejectUntrustedSystemviewRequester(request)
350+}
351+
352+func isTrustedSystemviewURL(raw string) bool {
353+ parsed, err := url.Parse(strings.TrimSpace(raw))
354+ if err != nil || parsed.Scheme == "" {
355+ return false
356+ }
357+ if strings.EqualFold(parsed.Scheme, actualInternalScheme) {
358+ return strings.EqualFold(parsed.Host, actualInternalHost)
359+ }
360+ if !strings.EqualFold(parsed.Scheme, "dumb") {
361+ return false
362+ }
363+ host := parsed.Host
364+ if host == "" {
365+ host = parsed.Opaque
366+ }
367+ if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
368+ host = host[:idx]
369+ }
370+ return isInternalPageHost(host)
371+}
372+
238 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {373 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
239 if h.transcoder == nil {374 if h.transcoder == nil {
240 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})375 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
@@ -255,7 +390,6 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
255 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {390 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
256 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})391 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
257 }392 }
258-
259 headers := make(map[string]string)393 headers := make(map[string]string)
260 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {394 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
261 headers["User-Agent"] = userAgent395 headers["User-Agent"] = userAgent
@@ -281,12 +415,48 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
281 headers: headers,415 headers: headers,
282 ctx: ctx,416 ctx: ctx,
283 cancel: cancel,417 cancel: cancel,
418+ validateSource: func(ctx context.Context) error {
419+ return validateTranscodeSourceURL(ctx, sourceParsed)
420+ },
284 logf: func() zerolog.Logger {421 logf: func() zerolog.Logger {
285 return h.logger.With().Str("component", "scheme-transcoding").Logger()422 return h.logger.With().Str("component", "scheme-transcoding").Logger()
286 },423 },
287 })424 })
288 }425 }
289 426
427+func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error {
428+ if source == nil || source.Host == "" {
429+ return fmt.Errorf("invalid src")
430+ }
431+ host := source.Hostname()
432+ if host == "" {
433+ return fmt.Errorf("invalid src host")
434+ }
435+ if ip := net.ParseIP(host); ip != nil {
436+ if isBlockedTranscodeIP(ip) {
437+ return fmt.Errorf("private src host not allowed")
438+ }
439+ return nil
440+ }
441+
442+ lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
443+ defer cancel()
444+ addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
445+ if err != nil || len(addrs) == 0 {
446+ return fmt.Errorf("src host could not be resolved")
447+ }
448+ for _, addr := range addrs {
449+ if isBlockedTranscodeIP(addr.IP) {
450+ return fmt.Errorf("private src host not allowed")
451+ }
452+ }
453+ return nil
454+}
455+
456+func isBlockedTranscodeIP(ip net.IP) bool {
457+ return netguard.IsBlockedTranscodeIP(ip)
458+}
459+
290 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {460 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
291 if build == nil {461 if build == nil {
292 return nil, fmt.Errorf("config payload builder not configured")462 return nil, fmt.Errorf("config payload builder not configured")
@@ -298,9 +468,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
298 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {468 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
299 data, err := resolveConfigPayload(build)469 data, err := resolveConfigPayload(build)
300 if err != nil {470 if err != nil {
301- return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})471+ return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
302 }472 }
303- return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)473+ return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data)
304 }474 }
305 475
306 // handleClipboardSet receives copied text from JS copy/cut events and writes476 // handleClipboardSet receives copied text from JS copy/cut events and writes
@@ -485,20 +655,26 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri
485 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {655 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
486 h.mu.RLock()656 h.mu.RLock()
487 hasAssets := h.assetsSet657 hasAssets := h.assetsSet
488- assetDir := h.assetDir
489 h.mu.RUnlock()658 h.mu.RUnlock()
490 659
491 if !hasAssets {660 if !hasAssets {
492 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")661 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
493 }662 }
494 663
495- relPath, ok := resolveAssetPath(u)664+ assetDir, relPath, ok := resolveAssetPath(u)
496 if !ok {665 if !ok {
497 return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")666 return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
498 }667 }
668+ if assetDir == "" {
669+ assetDir = h.assetDir
670+ }
671+
672+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
673+ if !ok {
674+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
675+ }
499 676
500- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))677+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
501- data, err := fs.ReadFile(h.assets, fullPath)
502 if err != nil {678 if err != nil {
503 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")679 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
504 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")680 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -511,14 +687,54 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
511 Int("size", len(data)).687 Int("size", len(data)).
512 Msg("serving asset")688 Msg("serving asset")
513 689
514- return h.newRawResourceHandler(http.StatusOK, contentType, data)690+ return newStaticResourceHandler(http.StatusOK, contentType, data, nil)
691+}
692+
693+func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
694+ assetDir = strings.Trim(assetDir, "/")
695+ if assetDir != systemviewsAssetDir {
696+ return "", "", false
697+ }
698+
699+ relPath = strings.TrimLeft(relPath, "/")
700+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
701+ return "", "", false
702+ }
703+
704+ cleanRelPath = path.Clean(relPath)
705+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
706+ return "", "", false
707+ }
708+
709+ fullPath = path.Join(assetDir, cleanRelPath)
710+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
711+ return "", "", false
712+ }
713+ return fullPath, cleanRelPath, true
714+}
715+
716+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
717+ if strings.HasSuffix(relPath, ".wasm") {
718+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
719+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
720+ if err != nil {
721+ return nil, err
722+ }
723+ if len(data) > maxSystemviewsWASMBytes {
724+ return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
725+ }
726+ return data, nil
727+ }
728+ }
729+ data, err := fs.ReadFile(assets, fullPath)
730+ return data, err
515 }731 }
516 732
517 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL733 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
518-// to a relative asset path inside assets/webui.734+// to a relative asset path inside assets/systemviews.
519-func resolveAssetPath(u *url.URL) (string, bool) {735+func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
520 if u == nil {736 if u == nil {
521- return "", false737+ return "", "", false
522 }738 }
523 739
524 if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {740 if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
@@ -528,57 +744,70 @@ func resolveAssetPath(u *url.URL) (string, bool) {
528 return resolveConceptualAssetPath(u)744 return resolveConceptualAssetPath(u)
529 }745 }
530 746
531-func resolveConceptualAssetPath(u *url.URL) (string, bool) {747+func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
532 if u == nil {748 if u == nil {
533- return "", false749+ return "", "", false
534 }750 }
535 751
536 if root, ok := pageRootFiles[u.Host]; ok {752 if root, ok := pageRootFiles[u.Host]; ok {
537- path := strings.TrimPrefix(u.Path, "/")753+ assetPath := strings.TrimPrefix(u.Path, "/")
538- if path == "" {754+ if assetPath == "" {
539- return root, true755+ return assetDirForPageHost(u.Host), root, true
540 }756 }
541 // Don't serve API paths as assets.757 // Don't serve API paths as assets.
542- if strings.HasPrefix(path, "api/") {758+ if strings.HasPrefix(assetPath, "api/") {
543- return "", false759+ return "", "", false
544 }760 }
545- return path, true761+ return assetDirForPageHost(u.Host), assetPath, true
546 }762 }
547 763
548- // Handle opaque URLs like dumb:home.764+ // Handle opaque URLs like dumb:history.
549 if root, ok := pageRootFiles[u.Opaque]; ok {765 if root, ok := pageRootFiles[u.Opaque]; ok {
550- return root, true766+ return assetDirForPageHost(u.Opaque), root, true
551 }767 }
552- return "", false768+ return "", "", false
553 }769 }
554 770
555-func resolveActualAssetPath(u *url.URL) (string, bool) {771+func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
556 if u == nil {772 if u == nil {
557- return "", false773+ return "", "", false
558 }774 }
559 775
560- path := strings.Trim(u.Path, "/")776+ assetPath := strings.Trim(u.Path, "/")
561- if path == "" {777+ if assetPath == "" {
562- return "", false778+ return "", "", false
563 }779 }
564 780
565- if root, ok := pageRootFiles[path]; ok {781+ parts := strings.SplitN(assetPath, "/", 2)
566- return root, true782+ page := parts[0]
783+ if root, ok := pageRootFiles[page]; ok {
784+ if len(parts) == 1 {
785+ return assetDirForPageHost(page), root, true
786+ }
787+ if page == historyPath && parts[1] == "crash" {
788+ return "", "", false
789+ }
790+ return assetDirForPageHost(page), parts[1], true
567 }791 }
568 792
569- if strings.HasPrefix(path, "api/") {793+ if strings.HasPrefix(assetPath, "api/") {
570- return "", false794+ return "", "", false
571 }795 }
572 796
573- parts := strings.SplitN(path, "/", 2)797+ if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
574- if len(parts) == 2 && isInternalPageHost(parts[0]) {798+ return systemviewsAssetDir, assetPath, true
575- if parts[1] == "crash" && parts[0] == homePath {
576- return "", false
577- }
578 }799 }
579 800
580- // Serve root assets like homepage.min.js, style.css and favicon.png.801+ return "", "", false
581- return path, true802+}
803+
804+func assetDirForPageHost(host string) string {
805+ switch host {
806+ case historyPath, favoritesPath, configPath, errorPath:
807+ return systemviewsAssetDir
808+ default:
809+ return ""
810+ }
582 }811 }
583 812
584 func isCEFCrashPageURL(u *url.URL) bool {813 func isCEFCrashPageURL(u *url.URL) bool {
@@ -588,8 +817,8 @@ func isCEFCrashPageURL(u *url.URL) bool {
588 817
589 switch {818 switch {
590 case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):819 case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
591- return strings.Trim(u.Path, "/") == homePath+"/crash"820+ return strings.Trim(u.Path, "/") == historyPath+"/crash"
592- case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath:821+ case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath:
593 return strings.Trim(u.Path, "/") == "crash"822 return strings.Trim(u.Path, "/") == "crash"
594 default:823 default:
595 return false824 return false
@@ -671,6 +900,10 @@ func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType str
671 return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))900 return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
672 }901 }
673 902
903+func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
904+ return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"})
905+}
906+
674 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {907 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
675 escaped := html.EscapeString(msg)908 escaped := html.EscapeString(msg)
676 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)909 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
@@ -686,6 +919,222 @@ func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef
686 return h.newAPIRawResourceHandler(status, "application/json", data)919 return h.newAPIRawResourceHandler(status, "application/json", data)
687 }920 }
688 921
922+func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
923+ data, err := json.Marshal(v)
924+ if err != nil {
925+ fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
926+ return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
927+ }
928+ return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
929+}
930+
931+func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler {
932+ return cefNewResourceHandler(&faviconResourceHandler{
933+ service: service,
934+ domain: domain,
935+ size: size,
936+ done: make(chan struct{}),
937+ headers: map[string]string{"Cache-Control": "no-store"},
938+ })
939+}
940+
941+const maxFaviconMemoryCacheEntries = 256
942+
943+var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries)
944+
945+type faviconCacheEntry struct {
946+ statusCode int
947+ contentType string
948+ data []byte
949+}
950+
951+type faviconMemoryCache struct {
952+ mu sync.Mutex
953+ max int
954+ entries map[string]faviconCacheEntry
955+ order []string
956+}
957+
958+func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache {
959+ return &faviconMemoryCache{
960+ max: maxEntries,
961+ entries: make(map[string]faviconCacheEntry),
962+ order: make([]string, 0, maxEntries),
963+ }
964+}
965+
966+func faviconCacheKey(domain string, size int) string {
967+ return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size)
968+}
969+
970+func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) {
971+ if c == nil || key == "" {
972+ return faviconCacheEntry{}, false
973+ }
974+ c.mu.Lock()
975+ defer c.mu.Unlock()
976+ entry, ok := c.entries[key]
977+ if !ok {
978+ return faviconCacheEntry{}, false
979+ }
980+ c.moveToBackLocked(key)
981+ return entry, true
982+}
983+
984+func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) {
985+ if c == nil || key == "" || c.max <= 0 {
986+ return
987+ }
988+ c.mu.Lock()
989+ defer c.mu.Unlock()
990+ if _, ok := c.entries[key]; !ok {
991+ c.order = append(c.order, key)
992+ } else {
993+ c.moveToBackLocked(key)
994+ }
995+ c.entries[key] = entry
996+ for len(c.order) > c.max {
997+ oldest := c.order[0]
998+ c.order = c.order[1:]
999+ delete(c.entries, oldest)
1000+ }
1001+}
1002+
1003+func (c *faviconMemoryCache) moveToBackLocked(key string) {
1004+ for i, candidate := range c.order {
1005+ if candidate != key {
1006+ continue
1007+ }
1008+ copy(c.order[i:], c.order[i+1:])
1009+ c.order[len(c.order)-1] = key
1010+ return
1011+ }
1012+}
1013+
1014+type faviconResourceHandler struct {
1015+ service port.FaviconService
1016+ domain string
1017+ size int
1018+ headers map[string]string
1019+ once sync.Once
1020+ done chan struct{}
1021+ data []byte
1022+ contentType string
1023+ statusCode int
1024+ offset int
1025+}
1026+
1027+func (rh *faviconResourceHandler) load() {
1028+ defer close(rh.done)
1029+ rh.statusCode = http.StatusNotFound
1030+ rh.contentType = "application/json"
1031+ rh.data = []byte(`{"error":"favicon not cached"}`)
1032+ if rh.service == nil {
1033+ return
1034+ }
1035+ cacheKey := faviconCacheKey(rh.domain, rh.size)
1036+ if entry, ok := systemviewFaviconCache.get(cacheKey); ok {
1037+ rh.statusCode = entry.statusCode
1038+ rh.contentType = entry.contentType
1039+ rh.data = entry.data
1040+ return
1041+ }
1042+ if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
1043+ return
1044+ }
1045+ diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)
1046+ if diskPath == "" {
1047+ return
1048+ }
1049+ data, err := os.ReadFile(diskPath)
1050+ if err != nil || len(data) == 0 {
1051+ return
1052+ }
1053+ rh.statusCode = http.StatusOK
1054+ rh.contentType = "image/png"
1055+ rh.data = data
1056+ systemviewFaviconCache.put(cacheKey, faviconCacheEntry{
1057+ statusCode: rh.statusCode,
1058+ contentType: rh.contentType,
1059+ data: data,
1060+ })
1061+}
1062+
1063+func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
1064+ if handleRequest != nil {
1065+ *handleRequest = 0
1066+ }
1067+ rh.once.Do(func() {
1068+ go func() {
1069+ rh.load()
1070+ if callback != nil {
1071+ callback.Cont()
1072+ }
1073+ }()
1074+ })
1075+ return 1
1076+}
1077+
1078+func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
1079+ rh.once.Do(func() {
1080+ go func() {
1081+ rh.load()
1082+ if callback != nil {
1083+ callback.Cont()
1084+ }
1085+ }()
1086+ })
1087+ return 1
1088+}
1089+
1090+func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1091+ <-rh.done
1092+ response.SetStatus(int32(rh.statusCode))
1093+ if text := http.StatusText(rh.statusCode); text != "" {
1094+ response.SetStatusText(text)
1095+ }
1096+ mimeType, charset := splitMimeCharset(rh.contentType)
1097+ response.SetMimeType(mimeType)
1098+ if charset != "" {
1099+ response.SetCharset(charset)
1100+ }
1101+ for name, value := range rh.headers {
1102+ response.SetHeaderByName(name, value, 1)
1103+ }
1104+ if responseLength != nil {
1105+ *responseLength = int64(len(rh.data))
1106+ }
1107+}
1108+
1109+func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1110+ return 0
1111+}
1112+
1113+func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 {
1114+ <-rh.done
1115+ if rh.offset >= len(rh.data) {
1116+ return 0
1117+ }
1118+ remaining := len(rh.data) - rh.offset
1119+ toRead := int(bytesToRead)
1120+ if toRead > remaining {
1121+ toRead = remaining
1122+ }
1123+ dst := unsafe.Slice((*byte)(dataOut), toRead)
1124+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
1125+ rh.offset += toRead
1126+ if bytesRead != nil {
1127+ *bytesRead = int32(toRead)
1128+ }
1129+ return 1
1130+}
1131+
1132+func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1133+ return 0
1134+}
1135+
1136+func (rh *faviconResourceHandler) Cancel() {}
1137+
689 // Open handles the request immediately (synchronous).1138 // Open handles the request immediately (synchronous).
690 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {1139 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
691 // Set handleRequest = true (1) to indicate we handle it immediately.1140 // Set handleRequest = true (1) to indicate we handle it immediately.
A internal/infrastructure/cef/scheme_handler_systemviews_test.go +99 -0
@@ -0,0 +1,99 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "testing"
6+
7+ "github.com/bnema/dumber/assets"
8+)
9+
10+func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ raw string
16+ wantDir string
17+ wantRel string
18+ }{
19+ {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML},
20+ {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML},
21+ {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML},
22+ {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
23+ {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"},
24+ {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
25+ {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML},
26+ {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
27+ {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ t.Parallel()
33+ u := mustParseURL(t, tt.raw)
34+ gotDir, gotRel, ok := resolveAssetPath(u)
35+ if !ok {
36+ t.Fatalf("resolveAssetPath(%q) = not ok", tt.raw)
37+ }
38+ if gotDir != tt.wantDir || gotRel != tt.wantRel {
39+ t.Fatalf("resolveAssetPath(%q) = (%q, %q), want (%q, %q)", tt.raw, gotDir, gotRel, tt.wantDir, tt.wantRel)
40+ }
41+ })
42+ }
43+}
44+
45+func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
46+ t.Parallel()
47+
48+ data, 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 {
53+ t.Fatalf("decompressed wasm too short: got %d bytes", len(data))
54+ }
55+ if string(data[:4]) != "\x00asm" {
56+ t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
57+ }
58+}
59+
60+func mustParseURL(t *testing.T, raw string) *url.URL {
61+ t.Helper()
62+ u, err := url.Parse(raw)
63+ if err != nil {
64+ t.Fatalf("url.Parse(%q) error = %v", raw, err)
65+ }
66+ return u
67+}
68+
69+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
70+ t.Parallel()
71+
72+ fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
73+ if !ok {
74+ t.Fatal("safeSystemviewsAssetPath() rejected safe cleaned path")
75+ }
76+ if fullPath != "systemviews/systemviews.css" || relPath != "systemviews.css" {
77+ t.Fatalf("safeSystemviewsAssetPath() = (%q, %q), want (systemviews/systemviews.css, systemviews.css)", fullPath, relPath)
78+ }
79+
80+ invalid := []struct {
81+ name string
82+ assetDir string
83+ relPath string
84+ }{
85+ {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
86+ {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
87+ {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
88+ {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
89+ {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
90+ }
91+ for _, tt := range invalid {
92+ t.Run(tt.name, func(t *testing.T) {
93+ t.Parallel()
94+ if fullPath, relPath, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath); ok {
95+ t.Fatalf("safeSystemviewsAssetPath(%q, %q) = (%q, %q, true), want rejected", tt.assetDir, tt.relPath, fullPath, relPath)
96+ }
97+ })
98+ }
99+}
M internal/infrastructure/cef/scheme_handler_test.go +272 -0
@@ -4,7 +4,9 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "errors"6 "errors"
7+ "net"
7 "net/http"8 "net/http"
9+ "net/url"
8 "testing"10 "testing"
9 11
10 purecef "github.com/bnema/purego-cef/cef"12 purecef "github.com/bnema/purego-cef/cef"
@@ -24,6 +26,31 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
24 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()26 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
25 }27 }
26 28
29+func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) {
30+ response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
31+}
32+
33+type panicFaviconService struct{}
34+
35+func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) {
36+ panic("unexpected favicon lookup")
37+}
38+func (panicFaviconService) Get(context.Context, string) ([]byte, error) {
39+ panic("unexpected favicon lookup")
40+}
41+func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") }
42+func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") }
43+func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") }
44+func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") }
45+func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error {
46+ panic("unexpected favicon lookup")
47+}
48+func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") }
49+func (panicFaviconService) EnsureDiskCache(context.Context, string) {
50+ panic("unexpected favicon lookup")
51+}
52+func (panicFaviconService) Close() {}
53+
27 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {54 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
28 data, err := resolveConfigPayload(func() ([]byte, error) {55 data, err := resolveConfigPayload(func() ([]byte, error) {
29 return []byte(`{"engine_type":"cef"}`), nil56 return []byte(`{"engine_type":"cef"}`), nil
@@ -60,6 +87,251 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
60 require.Contains(t, err.Error(), "default config payload builder")87 require.Contains(t, err.Error(), "default config payload builder")
61 }88 }
62 89
90+func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) {
91+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
92+ require.NoError(t, err)
93+
94+ request := cefmocks.NewMockRequest(t)
95+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
96+
97+ require.Nil(t, h.rejectForbiddenAPIOrigin(request))
98+}
99+
100+func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) {
101+ oldNewResourceHandler := cefNewResourceHandler
102+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
103+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
104+
105+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
106+ require.NoError(t, err)
107+
108+ request := cefmocks.NewMockRequest(t)
109+ request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
110+
111+ response := cefmocks.NewMockResponse(t)
112+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
113+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
114+ response.EXPECT().SetMimeType("application/json").Once()
115+ expectAPIResponseHeaders(response)
116+
117+ handler := h.rejectForbiddenAPIOrigin(request)
118+ require.NotNil(t, handler)
119+ var responseLength int64
120+ handler.GetResponseHeaders(response, &responseLength, 0)
121+ require.Positive(t, responseLength)
122+}
123+
124+func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testing.T) {
125+ oldNewResourceHandler := cefNewResourceHandler
126+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
127+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
128+
129+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
130+ require.NoError(t, err)
131+
132+ trustedOrigin := cefmocks.NewMockRequest(t)
133+ trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
134+ require.Nil(t, h.rejectUntrustedConfigRequester(trustedOrigin))
135+
136+ trustedReferrer := cefmocks.NewMockRequest(t)
137+ trustedReferrer.EXPECT().GetHeaderByName("Origin").Return("").Once()
138+ trustedReferrer.EXPECT().GetReferrerURL().Return("dumb://history").Once()
139+ require.Nil(t, h.rejectUntrustedConfigRequester(trustedReferrer))
140+
141+ emptyContext := cefmocks.NewMockRequest(t)
142+ emptyContext.EXPECT().GetHeaderByName("Origin").Return("").Once()
143+ emptyContext.EXPECT().GetReferrerURL().Return("").Once()
144+ emptyContext.EXPECT().GetHeaderByName("Referer").Return("").Once()
145+ denied := h.rejectUntrustedConfigRequester(emptyContext)
146+ require.NotNil(t, denied)
147+
148+ response := cefmocks.NewMockResponse(t)
149+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
150+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
151+ response.EXPECT().SetMimeType("application/json").Once()
152+ expectPrivateAPIResponseHeaders(response)
153+ var responseLength int64
154+ denied.GetResponseHeaders(response, &responseLength, 0)
155+ require.Positive(t, responseLength)
156+
157+ untrustedOrigin := cefmocks.NewMockRequest(t)
158+ untrustedOrigin.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
159+ require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin))
160+}
161+
162+func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) {
163+ oldNewResourceHandler := cefNewResourceHandler
164+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
165+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
166+
167+ h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
168+ require.NoError(t, err)
169+
170+ trusted := cefmocks.NewMockRequest(t)
171+ trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
172+ trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once()
173+ trusted.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(base64.StdEncoding.EncodeToString([]byte(`{"type":"missing"}`))).Once()
174+ trustedHandler := h.handleMessageAPI(trusted)
175+ require.NotNil(t, trustedHandler)
176+
177+ untrusted := cefmocks.NewMockRequest(t)
178+ untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
179+ untrusted.EXPECT().GetReferrerURL().Return("").Once()
180+ untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once()
181+ denied := h.handleMessageAPI(untrusted)
182+ require.NotNil(t, denied)
183+
184+ response := cefmocks.NewMockResponse(t)
185+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
186+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
187+ response.EXPECT().SetMimeType("application/json").Once()
188+ expectPrivateAPIResponseHeaders(response)
189+ var responseLength int64
190+ denied.GetResponseHeaders(response, &responseLength, 0)
191+ require.Positive(t, responseLength)
192+}
193+
194+func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {
195+ oldNewResourceHandler := cefNewResourceHandler
196+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
197+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
198+
199+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
200+ require.NoError(t, err)
201+
202+ response := cefmocks.NewMockResponse(t)
203+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
204+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
205+ response.EXPECT().SetMimeType("application/json").Once()
206+ expectPrivateAPIResponseHeaders(response)
207+
208+ handler := h.handleConfigAPI(h.currentConfigPayload)
209+ var responseLength int64
210+ handler.GetResponseHeaders(response, &responseLength, 0)
211+ require.Positive(t, responseLength)
212+}
213+
214+func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) {
215+ oldNewResourceHandler := cefNewResourceHandler
216+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
217+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
218+
219+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
220+ require.NoError(t, err)
221+
222+ request := cefmocks.NewMockRequest(t)
223+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
224+
225+ response := cefmocks.NewMockResponse(t)
226+ response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once()
227+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once()
228+ response.EXPECT().SetMimeType("text/plain").Once()
229+ response.EXPECT().SetCharset("utf-8").Once()
230+ expectPrivateAPIResponseHeaders(response)
231+
232+ handler := h.handleAPI(nil, http.MethodOptions, "/api/config", request)
233+ var responseLength int64
234+ handler.GetResponseHeaders(response, &responseLength, 0)
235+ require.Zero(t, responseLength)
236+}
237+
238+func TestIsTrustedSystemviewURL(t *testing.T) {
239+ t.Parallel()
240+
241+ for _, raw := range []string{
242+ "https://dumber.invalid/history",
243+ "dumb://history",
244+ "dumb:history",
245+ } {
246+ require.True(t, isTrustedSystemviewURL(raw), raw)
247+ }
248+ for _, raw := range []string{
249+ "",
250+ "https://evil.example/history",
251+ "dumb://api/favicon",
252+ "dumb://evil/history",
253+ } {
254+ require.False(t, isTrustedSystemviewURL(raw), raw)
255+ }
256+}
257+
258+func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) {
259+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
260+ require.NoError(t, err)
261+
262+ request := cefmocks.NewMockRequest(t)
263+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
264+
265+ require.Nil(t, h.rejectUntrustedFaviconRequester(request))
266+}
267+
268+func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedReferrer(t *testing.T) {
269+ oldNewResourceHandler := cefNewResourceHandler
270+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
271+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
272+
273+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
274+ require.NoError(t, err)
275+
276+ request := cefmocks.NewMockRequest(t)
277+ request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
278+
279+ require.NotNil(t, h.rejectUntrustedFaviconRequester(request))
280+}
281+
282+func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(t *testing.T) {
283+ oldNewResourceHandler := cefNewResourceHandler
284+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
285+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
286+
287+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
288+ require.NoError(t, err)
289+
290+ trusted := cefmocks.NewMockRequest(t)
291+ trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
292+ trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once()
293+ require.Nil(t, h.rejectUntrustedFaviconRequester(trusted))
294+
295+ untrusted := cefmocks.NewMockRequest(t)
296+ untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
297+ untrusted.EXPECT().GetReferrerURL().Return("").Once()
298+ untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once()
299+ require.NotNil(t, h.rejectUntrustedFaviconRequester(untrusted))
300+}
301+
302+func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) {
303+ oldNewResourceHandler := cefNewResourceHandler
304+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
305+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
306+
307+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
308+ require.NoError(t, err)
309+ h.setFaviconService(panicFaviconService{})
310+
311+ request := cefmocks.NewMockRequest(t)
312+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
313+ request.EXPECT().GetURL().Return(actualInternalOrigin + "/api/favicon?domain=example.com&size=32").Once()
314+
315+ require.NotNil(t, h.handleFaviconAPI(request))
316+}
317+
318+func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) {
319+ privateURL, err := url.Parse("http://127.0.0.1/video.mp4")
320+ require.NoError(t, err)
321+
322+ err = validateTranscodeSourceURL(context.Background(), privateURL)
323+ require.Error(t, err)
324+ require.Contains(t, err.Error(), "private src host")
325+}
326+
327+func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
328+ 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"} {
329+ t.Run(rawIP, func(t *testing.T) {
330+ require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP)))
331+ })
332+ }
333+}
334+
63 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {335 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
64 request := cefmocks.NewMockRequest(t)336 request := cefmocks.NewMockRequest(t)
65 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))337 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
M internal/infrastructure/cef/transcoding_handler.go +89 -17
@@ -24,6 +24,8 @@ var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
24 const (24 const (
25 maxTranscodingURLLength = 24025 maxTranscodingURLLength = 240
26 httpStatusOK = 20026 httpStatusOK = 200
27+ httpStatusForbidden = 403
28+ httpStatusBadGateway = 502
27 )29 )
28 30
29 // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler31 // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
@@ -34,14 +36,18 @@ const (
34 // ---------------------------------------------------------------------------36 // ---------------------------------------------------------------------------
35 37
36 type transcodingResourceHandler struct {38 type transcodingResourceHandler struct {
37- transcoder port.MediaTranscoder39+ transcoder port.MediaTranscoder
38- sourceURL string40+ sourceURL string
39- headers map[string]string41+ headers map[string]string
40- session port.TranscodeSession42+ session port.TranscodeSession
41- ctx context.Context43+ ctx context.Context
42- cancel context.CancelFunc44+ cancel context.CancelFunc
43- logf func() zerolog.Logger45+ logf func() zerolog.Logger
44- totalBytes int6446+ validateSource func(context.Context) error
47+ mu sync.Mutex
48+ startErr error
49+ startErrStatus int
50+ totalBytes int64
45 }51 }
46 52
47 // Open starts the transcode session asynchronously. CEF will call53 // Open starts the transcode session asynchronously. CEF will call
@@ -62,6 +68,19 @@ func (rh *transcodingResourceHandler) Open(
62 Msg("cef: starting transcoding resource stream")68 Msg("cef: starting transcoding resource stream")
63 69
64 go func() {70 go func() {
71+ if rh.validateSource != nil {
72+ if err := rh.validateSource(rh.ctx); err != nil {
73+ log.Warn().
74+ Err(err).
75+ Str("source_url", sourceURL).
76+ Msg("cef: rejected transcoding source")
77+ rh.setStartErr(err, httpStatusForbidden)
78+ rh.cancel()
79+ callback.Cont()
80+ return
81+ }
82+ }
83+
65 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)84 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
66 if err != nil {85 if err != nil {
67 log.Error().86 log.Error().
@@ -69,11 +88,14 @@ func (rh *transcodingResourceHandler) Open(
69 Str("source_url", sourceURL).88 Str("source_url", sourceURL).
70 Msg("cef: failed to start transcoding resource stream")89 Msg("cef: failed to start transcoding resource stream")
71 // Let CEF know we failed — Cancel will be called.90 // Let CEF know we failed — Cancel will be called.
91+ rh.setStartErr(err, httpStatusBadGateway)
72 rh.cancel()92 rh.cancel()
73 callback.Cont()93 callback.Cont()
74 return94 return
75 }95 }
96+ rh.mu.Lock()
76 rh.session = session97 rh.session = session
98+ rh.mu.Unlock()
77 log.Info().99 log.Info().
78 Str("source_url", sourceURL).100 Str("source_url", sourceURL).
79 Str("content_type", session.ContentType()).101 Str("content_type", session.ContentType()).
@@ -84,10 +106,52 @@ func (rh *transcodingResourceHandler) Open(
84 return 1106 return 1
85 }107 }
86 108
109+func (rh *transcodingResourceHandler) setStartErr(err error, status int) {
110+ rh.mu.Lock()
111+ defer rh.mu.Unlock()
112+ rh.startErr = err
113+ rh.startErrStatus = status
114+}
115+
116+func (rh *transcodingResourceHandler) getStartErr() (int, error) {
117+ rh.mu.Lock()
118+ defer rh.mu.Unlock()
119+ return rh.startErrStatus, rh.startErr
120+}
121+
122+func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 {
123+ rh.mu.Lock()
124+ defer rh.mu.Unlock()
125+ rh.totalBytes += int64(n)
126+ return rh.totalBytes
127+}
128+
129+func (rh *transcodingResourceHandler) getTotalBytes() int64 {
130+ rh.mu.Lock()
131+ defer rh.mu.Unlock()
132+ return rh.totalBytes
133+}
134+
87 // GetResponseHeaders sets the streaming response metadata.135 // GetResponseHeaders sets the streaming response metadata.
88 func (rh *transcodingResourceHandler) GetResponseHeaders(136 func (rh *transcodingResourceHandler) GetResponseHeaders(
89 response purecef.Response, responseLength *int64, _ uintptr,137 response purecef.Response, responseLength *int64, _ uintptr,
90 ) {138 ) {
139+ if status, startErr := rh.getStartErr(); startErr != nil {
140+ if status == 0 {
141+ status = httpStatusBadGateway
142+ }
143+ response.SetStatus(int32(status))
144+ if status == httpStatusForbidden {
145+ response.SetStatusText("Forbidden")
146+ } else {
147+ response.SetStatusText("Bad Gateway")
148+ }
149+ response.SetMimeType("text/plain")
150+ if responseLength != nil {
151+ *responseLength = 0
152+ }
153+ return
154+ }
91 response.SetStatus(httpStatusOK)155 response.SetStatus(httpStatusOK)
92 response.SetStatusText("OK")156 response.SetStatusText("OK")
93 response.SetMimeType("video/webm")157 response.SetMimeType("video/webm")
@@ -108,15 +172,18 @@ func (rh *transcodingResourceHandler) Read(
108 dataOut unsafe.Pointer, bytesToRead int32,172 dataOut unsafe.Pointer, bytesToRead int32,
109 bytesRead *int32, _ purecef.ResourceReadCallback,173 bytesRead *int32, _ purecef.ResourceReadCallback,
110 ) int32 {174 ) int32 {
111- if rh.session == nil {175+ rh.mu.Lock()
176+ session := rh.session
177+ rh.mu.Unlock()
178+ if session == nil {
112 return 0179 return 0
113 }180 }
114 181
115 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))182 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
116- n, err := rh.session.Read(dst)183+ n, err := session.Read(dst)
117 if n > 0 {184 if n > 0 {
118- rh.totalBytes += int64(n)185+ totalBytes := rh.addTotalBytes(n)
119- if rh.totalBytes == int64(n) {186+ if totalBytes == int64(n) {
120 rh.logger().Info().187 rh.logger().Info().
121 Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).188 Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).
122 Int("first_chunk_bytes", n).189 Int("first_chunk_bytes", n).
@@ -128,13 +195,14 @@ func (rh *transcodingResourceHandler) Read(
128 return 1195 return 1
129 }196 }
130 if err != nil {197 if err != nil {
198+ totalBytes := rh.getTotalBytes()
131 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)199 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
132 log := rh.logger().With().200 log := rh.logger().With().
133 Str("source_url", sourceURL).201 Str("source_url", sourceURL).
134- Int64("total_bytes", rh.totalBytes).202+ Int64("total_bytes", totalBytes).
135 Logger()203 Logger()
136 switch {204 switch {
137- case errors.Is(err, io.EOF) && rh.totalBytes == 0:205+ case errors.Is(err, io.EOF) && totalBytes == 0:
138 log.Warn().Msg("cef: transcoding resource stream ended before producing data")206 log.Warn().Msg("cef: transcoding resource stream ended before producing data")
139 case errors.Is(err, io.EOF):207 case errors.Is(err, io.EOF):
140 log.Debug().Msg("cef: transcoding resource stream reached EOF")208 log.Debug().Msg("cef: transcoding resource stream reached EOF")
@@ -149,12 +217,16 @@ func (rh *transcodingResourceHandler) Read(
149 // Cancel aborts the transcode session and releases resources.217 // Cancel aborts the transcode session and releases resources.
150 func (rh *transcodingResourceHandler) Cancel() {218 func (rh *transcodingResourceHandler) Cancel() {
151 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)219 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
220+ totalBytes := rh.getTotalBytes()
152 rh.logger().Debug().221 rh.logger().Debug().
153 Str("source_url", sourceURL).222 Str("source_url", sourceURL).
154- Int64("total_bytes", rh.totalBytes).223+ Int64("total_bytes", totalBytes).
155 Msg("cef: transcoding resource stream canceled")224 Msg("cef: transcoding resource stream canceled")
156- if rh.session != nil {225+ rh.mu.Lock()
157- if err := rh.session.Close(); err != nil {226+ session := rh.session
227+ rh.mu.Unlock()
228+ if session != nil {
229+ if err := session.Close(); err != nil {
158 rh.logger().Warn().230 rh.logger().Warn().
159 Str("source_url", sourceURL).231 Str("source_url", sourceURL).
160 Err(err).232 Err(err).
M internal/infrastructure/config/defaults.go +11 -8
@@ -240,14 +240,17 @@ func DefaultConfig() *Config {
240 },240 },
241 Shortcuts: GlobalShortcutsConfig{241 Shortcuts: GlobalShortcutsConfig{
242 Actions: map[string]ActionBinding{242 Actions: map[string]ActionBinding{
243- "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},243+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
244- "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},244+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
245- "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},245+ "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},
246- "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},246+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
247- "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},247+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
248- "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},248+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
249- "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},249+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
250- "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},250+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
251+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
252+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
253+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
251 },254 },
252 },255 },
253 FloatingPane: FloatingPaneConfig{256 FloatingPane: FloatingPaneConfig{
M internal/infrastructure/config/defaults_test.go +17 -0
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
7 )8 )
8 9
9 func TestDefaultConfig_CoreDefaults(t *testing.T) {10 func TestDefaultConfig_CoreDefaults(t *testing.T) {
@@ -27,6 +28,22 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
27 assert.False(t, cfg.Engine.CEF.TraceHandlers)28 assert.False(t, cfg.Engine.CEF.TraceHandlers)
28 assert.True(t, cfg.Engine.WebKit.ITPEnabled)29 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
29 30
31+ // System view shortcuts are first-class global actions.
32+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
33+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
34+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
35+
30 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.36 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
31 // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above).37 // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above).
32 }38 }
39+
40+func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) {
41+ t.Helper()
42+ binding, ok := actions[action]
43+ require.True(t, ok, "missing action binding %q", action)
44+ if len(keys) == 0 {
45+ assert.Empty(t, binding.Keys)
46+ return
47+ }
48+ assert.Equal(t, keys, binding.Keys)
49+}
M internal/infrastructure/config/floating_pane_config_test.go +2 -2
@@ -15,7 +15,7 @@ func TestFloatingPaneConfig_Defaults(t *testing.T) {
15 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)15 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
16 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)16 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
17 17
18- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
19 require.True(t, ok)19 require.True(t, ok)
20 assert.Equal(t, []string{"alt+f"}, binding.Keys)20 assert.Equal(t, []string{"alt+f"}, binding.Keys)
21 }21 }
@@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
31 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)31 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
32 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)32 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
33 33
34- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
35 require.True(t, ok)35 require.True(t, ok)
36 assert.Equal(t, []string{"alt+f"}, binding.Keys)36 assert.Equal(t, []string{"alt+f"}, binding.Keys)
37 }37 }
M internal/infrastructure/config/loader.go +2 -0
@@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) {
254 config.Engine.Profile = ProfileDefault254 config.Engine.Profile = ProfileDefault
255 case string(ProfileLite):255 case string(ProfileLite):
256 config.Engine.Profile = ProfileLite256 config.Engine.Profile = ProfileLite
257+ case string(ProfileBalanced):
258+ config.Engine.Profile = ProfileBalanced
257 case string(ProfileMax):259 case string(ProfileMax):
258 config.Engine.Profile = ProfileMax260 config.Engine.Profile = ProfileMax
259 case string(ProfileCustom):261 case string(ProfileCustom):
M internal/infrastructure/config/migrate.go +8 -0
@@ -777,6 +777,10 @@ func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[
777 if _, exists := userActions[actionName]; exists {777 if _, exists := userActions[actionName]; exists {
778 continue778 continue
779 }779 }
780+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
781+ if _, exists := userActions[legacyActionName]; exists {
782+ continue
783+ }
780 missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)784 missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
781 }785 }
782 786
@@ -801,6 +805,10 @@ func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any
801 if _, exists := userActions[actionName]; exists {805 if _, exists := userActions[actionName]; exists {
802 continue806 continue
803 }807 }
808+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
809+ if _, exists := userActions[legacyActionName]; exists {
810+ continue
811+ }
804 userActions[actionName] = defaultAction812 userActions[actionName] = defaultAction
805 }813 }
806 814
M internal/infrastructure/config/migrate_test.go +11 -4
@@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
695 for actionName, value := range defaultActions {695 for actionName, value := range defaultActions {
696 userActions[actionName] = value696 userActions[actionName] = value
697 }697 }
698- delete(userActions, "toggle_floating_pane")698+ delete(userActions, "toggle-floating-pane")
699 699
700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701 "workspace.shortcuts.actions": userActions,701 "workspace.shortcuts.actions": userActions,
702 })702 })
703 703
704- assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)
705 }705 }
706 706
707 func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {707 func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
@@ -750,7 +750,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
750 actions, ok := actionsAny.(map[string]any)750 actions, ok := actionsAny.(map[string]any)
751 require.True(t, ok)751 require.True(t, ok)
752 752
753- toggleAny, hasToggle := actions["toggle_floating_pane"]753+ toggleAny, hasToggle := actions["toggle-floating-pane"]
754 assert.True(t, hasToggle)754 assert.True(t, hasToggle)
755 assert.NotNil(t, toggleAny)755 assert.NotNil(t, toggleAny)
756 756
@@ -759,12 +759,19 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
759 closePane, ok := closePaneAny.(map[string]any)759 closePane, ok := closePaneAny.(map[string]any)
760 require.True(t, ok)760 require.True(t, ok)
761 assert.Equal(t, "Custom close pane", closePane["desc"])761 assert.Equal(t, "Custom close pane", closePane["desc"])
762+ assert.NotContains(t, actions, "close-pane")
763+ assert.NotContains(t, actions, "next-tab")
764+ assert.NotContains(t, actions, "previous-tab")
765+ assert.NotContains(t, actions, "consume-or-expel-left")
766+ assert.NotContains(t, actions, "consume-or-expel-right")
767+ assert.NotContains(t, actions, "consume-or-expel-up")
768+ assert.NotContains(t, actions, "consume-or-expel-down")
762 }769 }
763 770
764 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {771 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765 m := NewMigrator()772 m := NewMigrator()
766 773
767- value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")774+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
768 775
769 action, ok := value.(ActionBinding)776 action, ok := value.(ActionBinding)
770 require.True(t, ok)777 require.True(t, ok)
M internal/infrastructure/config/profile.go +9 -1
@@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re
85 switch cfg.Profile {85 switch cfg.Profile {
86 case ProfileLite:86 case ProfileLite:
87 return resolveLiteProfile(hw)87 return resolveLiteProfile(hw)
88+ case ProfileBalanced:
89+ return resolveBalancedProfile()
88 case ProfileMax:90 case ProfileMax:
89 return resolveMaxProfile(hw)91 return resolveMaxProfile(hw)
90 case ProfileCustom:92 case ProfileCustom:
@@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings {
116 }118 }
117 }119 }
118 120
121+// resolveBalancedProfile preserves the legacy balanced profile as WebKit's
122+// default tuning with the normal WebView prewarm count.
123+func resolveBalancedProfile() ResolvedPerformanceSettings {
124+ return resolveDefaultProfile()
125+}
126+
119 // resolveLiteProfile returns settings optimized for low-RAM systems.127 // resolveLiteProfile returns settings optimized for low-RAM systems.
120 // Uses hardware info to scale CPU threads if available.128 // Uses hardware info to scale CPU threads if available.
121 func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {129 func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
@@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
299 // IsValidPerformanceProfile returns true if the profile name is recognized.307 // IsValidPerformanceProfile returns true if the profile name is recognized.
300 func IsValidPerformanceProfile(profile PerformanceProfile) bool {308 func IsValidPerformanceProfile(profile PerformanceProfile) bool {
301 switch profile {309 switch profile {
302- case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "":310+ case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "":
303 return true311 return true
304 default:312 default:
305 return false313 return false
M internal/infrastructure/config/profile_test.go +17 -0
@@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
44 }44 }
45 }45 }
46 46
47+func TestResolvePerformanceProfile_Balanced(t *testing.T) {
48+ cfg := &PerformanceConfig{Profile: ProfileBalanced}
49+
50+ result := ResolvePerformanceProfile(cfg, nil)
51+
52+ if result.SkiaCPUPaintingThreads != 0 {
53+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
54+ }
55+ if result.SkiaGPUPaintingThreads != -1 {
56+ t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads)
57+ }
58+ if result.WebViewPoolPrewarmCount != 4 {
59+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
60+ }
61+}
62+
47 func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {63 func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
48 cfg := &PerformanceConfig{64 cfg := &PerformanceConfig{
49 Profile: ProfileLite,65 Profile: ProfileLite,
@@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) {
317 }{333 }{
318 {ProfileDefault, true},334 {ProfileDefault, true},
319 {ProfileLite, true},335 {ProfileLite, true},
336+ {ProfileBalanced, true},
320 {ProfileMax, true},337 {ProfileMax, true},
321 {ProfileCustom, true},338 {ProfileCustom, true},
322 {"", true}, // Empty is valid (treated as default)339 {"", true}, // Empty is valid (treated as default)
M internal/infrastructure/config/schema.go +3 -1
@@ -170,6 +170,8 @@ const (
170 ProfileDefault PerformanceProfile = "default"170 ProfileDefault PerformanceProfile = "default"
171 // ProfileLite reduces resource usage for low-RAM systems.171 // ProfileLite reduces resource usage for low-RAM systems.
172 ProfileLite PerformanceProfile = "lite"172 ProfileLite PerformanceProfile = "lite"
173+ // ProfileBalanced is a conservative compatibility preset between lite and max.
174+ ProfileBalanced PerformanceProfile = "balanced"
173 // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).175 // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).
174 ProfileMax PerformanceProfile = "max"176 ProfileMax PerformanceProfile = "max"
175 // ProfileCustom allows full control over individual performance settings.177 // ProfileCustom allows full control over individual performance settings.
@@ -226,7 +228,7 @@ type HistoryConfig struct {
226 // These are not exposed in dumb://config UI but can be set in config file.228 // These are not exposed in dumb://config UI but can be set in config file.
227 type PerformanceConfig struct {229 type PerformanceConfig struct {
228 // Profile selects a preset performance configuration.230 // Profile selects a preset performance configuration.
229- // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control)231+ // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control)
230 // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.232 // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.
231 // Default: "default"233 // Default: "default"
232 Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`234 Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`
M internal/infrastructure/config/schema_provider.go +1 -1
@@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
905 Type: "string",905 Type: "string",
906 Default: string(defaults.Engine.Profile),906 Default: string(defaults.Engine.Profile),
907 Description: "Performance profile preset (custom enables manual tuning)",907 Description: "Performance profile preset (custom enables manual tuning)",
908- Values: []string{"default", "lite", "max", "custom"},908+ Values: []string{"default", "lite", "balanced", "max", "custom"},
909 Section: SectionPerformance,909 Section: SectionPerformance,
910 },910 },
911 {911 {
A internal/infrastructure/config/systemview_reader.go +41 -0
@@ -0,0 +1,41 @@
1+package config
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type systemviewConfigReader struct {
11+ hwSurveyor port.HardwareSurveyor
12+}
13+
14+var _ port.SystemviewConfigReader = (*systemviewConfigReader)(nil)
15+
16+// NewSystemviewConfigReader creates a read-side config payload reader.
17+func NewSystemviewConfigReader(hwSurveyor port.HardwareSurveyor) port.SystemviewConfigReader {
18+ return &systemviewConfigReader{hwSurveyor: hwSurveyor}
19+}
20+
21+func (r *systemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
22+ return r.build(ctx, Get())
23+}
24+
25+func (r *systemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
26+ return r.build(ctx, DefaultConfig())
27+}
28+
29+func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (dto.SystemviewConfigPayload, error) {
30+ if cfg == nil {
31+ return dto.SystemviewConfigPayload{}, nil
32+ }
33+
34+ var hw *port.HardwareInfo
35+ if r.hwSurveyor != nil {
36+ survey := r.hwSurveyor.Survey(ctx)
37+ hw = &survey
38+ }
39+
40+ return BuildSystemviewConfigPayload(cfg, hw), nil
41+}
M internal/infrastructure/config/validation.go +1 -1
@@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string {
483 // Validate profile name483 // Validate profile name
484 if !IsValidPerformanceProfile(config.Engine.Profile) {484 if !IsValidPerformanceProfile(config.Engine.Profile) {
485 validationErrors = append(validationErrors, fmt.Sprintf(485 validationErrors = append(validationErrors, fmt.Sprintf(
486- "engine.profile must be one of: default, lite, max, custom (got: %s)",486+ "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
487 config.Engine.Profile,487 config.Engine.Profile,
488 ))488 ))
489 }489 }
M internal/infrastructure/config/webui_gateway.go +2 -2
@@ -4,7 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7- "github.com/bnema/dumber/internal/application/port"7+ "github.com/bnema/dumber/internal/application/dto"
8 )8 )
9 9
10 type WebUIConfigGateway struct {10 type WebUIConfigGateway struct {
@@ -33,7 +33,7 @@ func clampInt(v, lo, hi int) int {
33 return v33 return v
34 }34 }
35 35
36-func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {36+func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
37 _ = ctx37 _ = ctx
38 if g == nil || g.mgr == nil {38 if g == nil || g.mgr == nil {
39 return fmt.Errorf("config manager not initialized")39 return fmt.Errorf("config manager not initialized")
M internal/infrastructure/config/webui_payload.go +78 -95
@@ -1,118 +1,101 @@
1 package config1 package config
2 2
3-import "github.com/bnema/dumber/internal/application/port"3+import (
4+ "github.com/bnema/dumber/internal/application/dto"
5+ "github.com/bnema/dumber/internal/application/port"
6+)
4 7
5-// WebUIConfigAppearancePayload is the JSON shape expected by dumb://config.8+// BuildSystemviewConfigPayload projects the full config into the stable DTO used by
6-type WebUIConfigAppearancePayload struct {9+// the settings page across engines.
7- SansFont string `json:"sans_font"`10+func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) dto.SystemviewConfigPayload {
8- SerifFont string `json:"serif_font"`11+ payload := dto.SystemviewConfigPayload{
9- MonospaceFont string `json:"monospace_font"`12+ SearchShortcuts: map[string]dto.SearchShortcut{},
10- DefaultFontSize int `json:"default_font_size"`13+ }
11- ColorScheme string `json:"color_scheme"`14+ if cfg == nil {
12- LightPalette ColorPalette `json:"light_palette"`15+ return payload
13- DarkPalette ColorPalette `json:"dark_palette"`16+ }
14-}
15 17
16-// WebUIConfigHardwarePayload exposes detected hardware to the settings UI.18+ perfCfg := PerformanceConfigFromEngine(&cfg.Engine)
17-type WebUIConfigHardwarePayload struct {19+ resolved := ResolvePerformanceProfile(&perfCfg, hw)
18- CPUCores int `json:"cpu_cores"`
19- CPUThreads int `json:"cpu_threads"`
20- TotalRAMMB int `json:"total_ram_mb"`
21- GPUVendor string `json:"gpu_vendor"`
22- GPUName string `json:"gpu_name"`
23- VRAMMB int `json:"vram_mb"`
24-}
25 20
26-// WebUIConfigCustomPerformancePayload holds user-editable custom profile values.21+ payload.Appearance = buildSystemviewAppearancePayload(cfg.Appearance)
27-type WebUIConfigCustomPerformancePayload struct {22+ payload.DefaultUIScale = cfg.DefaultUIScale
28- SkiaCPUThreads int `json:"skia_cpu_threads"`23+ payload.DefaultSearchEngine = cfg.DefaultSearchEngine
29- SkiaGPUThreads int `json:"skia_gpu_threads"`24+ payload.SearchShortcuts = buildSystemviewSearchShortcutsPayload(cfg.SearchShortcuts)
30- WebProcessMemoryMB int `json:"web_process_memory_mb"`25+ payload.EngineType = cfg.Engine.ResolveEngineType()
31- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`26+ payload.Performance = dto.SystemviewPerformancePayload{
32- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`27+ Profile: string(cfg.Engine.Profile),
33-}28+ Custom: dto.SystemviewCustomPerformancePayload{
29+ SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
30+ SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
31+ WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
32+ NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
33+ WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
34+ },
35+ Resolved: dto.SystemviewResolvedPerformancePayload{
36+ SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
37+ SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
38+ WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
39+ NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
40+ WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
41+ ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
42+ StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
43+ },
44+ Hardware: buildSystemviewHardwarePayload(hw),
45+ }
34 46
35-// WebUIConfigResolvedPerformancePayload holds the resolved effective values.47+ return payload
36-type WebUIConfigResolvedPerformancePayload struct {
37- SkiaCPUThreads int `json:"skia_cpu_threads"`
38- SkiaGPUThreads int `json:"skia_gpu_threads"`
39- WebProcessMemoryMB int `json:"web_process_memory_mb"`
40- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
41- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
42- ConservativeThreshold float64 `json:"conservative_threshold"`
43- StrictThreshold float64 `json:"strict_threshold"`
44 }48 }
45 49
46-// WebUIConfigPerformancePayload is the performance tab payload expected by the UI.50+func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppearanceConfig {
47-type WebUIConfigPerformancePayload struct {51+ defaults := DefaultConfig().Appearance
48- Profile string `json:"profile"`52+ return dto.WebUIAppearanceConfig{
49- Custom WebUIConfigCustomPerformancePayload `json:"custom"`53+ SansFont: appearance.SansFont,
50- Resolved WebUIConfigResolvedPerformancePayload `json:"resolved"`54+ SerifFont: appearance.SerifFont,
51- Hardware WebUIConfigHardwarePayload `json:"hardware"`55+ MonospaceFont: appearance.MonospaceFont,
56+ DefaultFontSize: appearance.DefaultFontSize,
57+ ColorScheme: appearance.ColorScheme,
58+ LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),
59+ DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette),
60+ }
52 }61 }
53 62
54-// WebUIConfigPayload is the full JSON shape expected by dumb://config.63+func buildSystemviewPalettePayload(palette, fallback ColorPalette) dto.ColorPalette {
55-type WebUIConfigPayload struct {64+ return dto.ColorPalette{
56- Appearance WebUIConfigAppearancePayload `json:"appearance"`65+ Background: nonEmptyColor(palette.Background, fallback.Background),
57- Performance WebUIConfigPerformancePayload `json:"performance"`66+ Surface: nonEmptyColor(palette.Surface, fallback.Surface),
58- DefaultUIScale float64 `json:"default_ui_scale"`67+ SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),
59- DefaultSearchEngine string `json:"default_search_engine"`68+ Text: nonEmptyColor(palette.Text, fallback.Text),
60- SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`69+ Muted: nonEmptyColor(palette.Muted, fallback.Muted),
61- EngineType string `json:"engine_type"`70+ Accent: nonEmptyColor(palette.Accent, fallback.Accent),
71+ Border: nonEmptyColor(palette.Border, fallback.Border),
72+ }
62 }73 }
63 74
64-// BuildWebUIConfigPayload projects the full config into the stable DTO used by75+func nonEmptyColor(value, fallback string) string {
65-// the settings page across engines.76+ if value != "" {
66-func BuildWebUIConfigPayload(cfg *Config, hw *port.HardwareInfo) WebUIConfigPayload {77+ return value
67- if cfg == nil {
68- return WebUIConfigPayload{}
69 }78 }
79+ return fallback
80+}
70 81
71- perfCfg := PerformanceConfigFromEngine(&cfg.Engine)82+func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]dto.SearchShortcut {
72- resolved := ResolvePerformanceProfile(&perfCfg, hw)83+ if len(shortcuts) == 0 {
84+ return map[string]dto.SearchShortcut{}
85+ }
73 86
74- return WebUIConfigPayload{87+ result := make(map[string]dto.SearchShortcut, len(shortcuts))
75- DefaultUIScale: cfg.DefaultUIScale,88+ for key, shortcut := range shortcuts {
76- DefaultSearchEngine: cfg.DefaultSearchEngine,89+ result[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
77- SearchShortcuts: cfg.SearchShortcuts,
78- EngineType: cfg.Engine.ResolveEngineType(),
79- Appearance: WebUIConfigAppearancePayload{
80- SansFont: cfg.Appearance.SansFont,
81- SerifFont: cfg.Appearance.SerifFont,
82- MonospaceFont: cfg.Appearance.MonospaceFont,
83- DefaultFontSize: cfg.Appearance.DefaultFontSize,
84- ColorScheme: cfg.Appearance.ColorScheme,
85- LightPalette: cfg.Appearance.LightPalette,
86- DarkPalette: cfg.Appearance.DarkPalette,
87- },
88- Performance: WebUIConfigPerformancePayload{
89- Profile: string(cfg.Engine.Profile),
90- Custom: WebUIConfigCustomPerformancePayload{
91- SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
92- SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
93- WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
94- NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
95- WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
96- },
97- Resolved: WebUIConfigResolvedPerformancePayload{
98- SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
99- SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
100- WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
101- NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
102- WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
103- ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
104- StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
105- },
106- Hardware: buildWebUIHardwarePayload(hw),
107- },
108 }90 }
91+ return result
109 }92 }
110 93
111-func buildWebUIHardwarePayload(hw *port.HardwareInfo) WebUIConfigHardwarePayload {94+func buildSystemviewHardwarePayload(hw *port.HardwareInfo) dto.SystemviewHardwarePayload {
112 if hw == nil {95 if hw == nil {
113- return WebUIConfigHardwarePayload{}96+ return dto.SystemviewHardwarePayload{}
114 }97 }
115- return WebUIConfigHardwarePayload{98+ return dto.SystemviewHardwarePayload{
116 CPUCores: hw.CPUCores,99 CPUCores: hw.CPUCores,
117 CPUThreads: hw.CPUThreads,100 CPUThreads: hw.CPUThreads,
118 TotalRAMMB: hw.TotalRAMMB(),101 TotalRAMMB: hw.TotalRAMMB(),
A internal/infrastructure/config/webui_payload_test.go +27 -0
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestBuildSystemviewConfigPayload_EmptySearchShortcutsUsesEmptyMap(t *testing.T) {
10+ cfg := DefaultConfig()
11+ cfg.SearchShortcuts = map[string]SearchShortcut{}
12+ payload := BuildSystemviewConfigPayload(cfg, nil)
13+ require.NotNil(t, payload.SearchShortcuts)
14+ require.Empty(t, payload.SearchShortcuts)
15+}
16+
17+func TestBuildSystemviewConfigPayload_FillsMissingPaletteValuesFromDefaults(t *testing.T) {
18+ cfg := DefaultConfig()
19+ cfg.Appearance.LightPalette.Background = ""
20+ cfg.Appearance.DarkPalette.Accent = ""
21+
22+ payload := BuildSystemviewConfigPayload(cfg, nil)
23+ defaults := DefaultConfig()
24+
25+ require.Equal(t, defaults.Appearance.LightPalette.Background, payload.Appearance.LightPalette.Background)
26+ require.Equal(t, defaults.Appearance.DarkPalette.Accent, payload.Appearance.DarkPalette.Accent)
27+}
M internal/infrastructure/handlers/config.go +11 -6
@@ -5,18 +5,19 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
10 )11 )
11 12
12 // ConfigHandler handles configuration-related messages.13 // ConfigHandler handles configuration-related messages.
13 type ConfigHandler struct {14 type ConfigHandler struct {
14- saveConfig func(context.Context, port.WebUIConfig) error15+ saveConfig func(context.Context, dto.WebUIConfig) error
15 }16 }
16 17
17 // NewConfigHandler creates a new ConfigHandler.18 // NewConfigHandler creates a new ConfigHandler.
18 // saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute).19 // saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute).
19-func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) *ConfigHandler {20+func NewConfigHandler(saveConfig func(context.Context, dto.WebUIConfig) error) *ConfigHandler {
20 if saveConfig == nil {21 if saveConfig == nil {
21 panic("NewConfigHandler: saveConfig must not be nil")22 panic("NewConfigHandler: saveConfig must not be nil")
22 }23 }
@@ -24,7 +25,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error)
24 }25 }
25 26
26 // Handle processes the save_config message.27 // Handle processes the save_config message.
27-func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {28+func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, payload json.RawMessage) (any, error) {
28 if h == nil {29 if h == nil {
29 return nil, fmt.Errorf("config handler is nil")30 return nil, fmt.Errorf("config handler is nil")
30 }31 }
@@ -33,13 +34,17 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
33 }34 }
34 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()35 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
35 36
36- var payloadCfg port.WebUIConfig37+ var payloadCfg dto.WebUIConfig
37 if err := json.Unmarshal(payload, &payloadCfg); err != nil {38 if err := json.Unmarshal(payload, &payloadCfg); err != nil {
38 log.Error().Err(err).Msg("failed to unmarshal config payload")39 log.Error().Err(err).Msg("failed to unmarshal config payload")
39 return nil, fmt.Errorf("invalid config format: %w", err)40 return nil, fmt.Errorf("invalid config format: %w", err)
40 }41 }
41 42
42- log.Info().Msg("saving appearance configuration from webui")43+ source := "webui"
44+ if viewID != 0 {
45+ source = fmt.Sprintf("webview:%d", viewID)
46+ }
47+ log.Info().Str("source", source).Msg("saving appearance configuration")
43 48
44 if err := h.saveConfig(ctx, payloadCfg); err != nil {49 if err := h.saveConfig(ctx, payloadCfg); err != nil {
45 log.Error().Err(err).Msg("failed to save config")50 log.Error().Err(err).Msg("failed to save config")
@@ -52,7 +57,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
52 // RegisterConfigHandlers registers configuration handlers with the router.57 // RegisterConfigHandlers registers configuration handlers with the router.
53 func RegisterConfigHandlers(58 func RegisterConfigHandlers(
54 ctx context.Context, router port.WebUIHandlerRouter,59 ctx context.Context, router port.WebUIHandlerRouter,
55- saveConfig func(context.Context, port.WebUIConfig) error,60+ saveConfig func(context.Context, dto.WebUIConfig) error,
56 ) error {61 ) error {
57 handler := NewConfigHandler(saveConfig)62 handler := NewConfigHandler(saveConfig)
58 63
M internal/infrastructure/handlers/homepage/favorites.go +168 -4
@@ -3,12 +3,37 @@ package homepage
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "fmt"
7+ "strings"
6 8
9+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
10 )13 )
11 14
15+func folderIDFromInt64(id *int64) (*entity.FolderID, error) {
16+ if id == nil {
17+ return nil, nil
18+ }
19+ if *id <= 0 {
20+ return nil, fmt.Errorf("folder id must be positive")
21+ }
22+ folderID := entity.FolderID(*id)
23+ return &folderID, nil
24+}
25+
26+func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {
27+ out := make([]entity.TagID, 0, len(ids))
28+ for _, id := range ids {
29+ if id <= 0 {
30+ return nil, fmt.Errorf("tag id must be positive")
31+ }
32+ out = append(out, entity.TagID(id))
33+ }
34+ return out, nil
35+}
36+
12 // FavoritesHandlers handles favorite-related messages from the homepage.37 // FavoritesHandlers handles favorite-related messages from the homepage.
13 type FavoritesHandlers struct {38 type FavoritesHandlers struct {
14 favoritesUC port.HomepageFavorites39 favoritesUC port.HomepageFavorites
@@ -39,6 +64,138 @@ func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler {
39 })64 })
40 }65 }
41 66
67+type favoriteCreateRequest struct {
68+ RequestID string `json:"requestId"`
69+ URL string `json:"url"`
70+ Title string `json:"title"`
71+ FaviconURL string `json:"favicon_url"`
72+ FolderID *int64 `json:"folder_id"`
73+ Tags []int64 `json:"tags"`
74+}
75+
76+// HandleCreate handles favorite_create messages.
77+func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
78+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
79+ log := logging.FromContext(ctx)
80+
81+ var req favoriteCreateRequest
82+ if err := json.Unmarshal(payload, &req); err != nil {
83+ return NewErrorResponse("", err), nil
84+ }
85+
86+ log.Debug().
87+ Str("request_id", req.RequestID).
88+ Msg("handling favorite_create")
89+
90+ trimmedURL := strings.TrimSpace(req.URL)
91+ if trimmedURL == "" {
92+ return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
93+ }
94+ folderID, err := folderIDFromInt64(req.FolderID)
95+ if err != nil {
96+ return NewErrorResponse(req.RequestID, err), nil
97+ }
98+ tags, err := tagIDsFromInt64s(req.Tags)
99+ if err != nil {
100+ return NewErrorResponse(req.RequestID, err), nil
101+ }
102+
103+ input := dto.FavoriteCreateInput{
104+ URL: trimmedURL,
105+ Title: req.Title,
106+ FaviconURL: req.FaviconURL,
107+ FolderID: folderID,
108+ Tags: tags,
109+ }
110+ favorite, err := h.favoritesUC.AddFavorite(ctx, input)
111+ if err != nil {
112+ return NewErrorResponse(req.RequestID, err), nil
113+ }
114+
115+ return NewSuccessResponse(req.RequestID, favorite), nil
116+ })
117+}
118+
119+type favoriteUpdateRequest struct {
120+ RequestID string `json:"requestId"`
121+ ID int64 `json:"id"`
122+ Title string `json:"title"`
123+ FaviconURL string `json:"favicon_url"`
124+ FolderID *int64 `json:"folder_id"`
125+ ShortcutKey *int `json:"shortcut_key"`
126+}
127+
128+// HandleUpdate handles favorite_update messages.
129+func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
130+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
131+ log := logging.FromContext(ctx)
132+
133+ var req favoriteUpdateRequest
134+ if err := json.Unmarshal(payload, &req); err != nil {
135+ return NewErrorResponse("", err), nil
136+ }
137+
138+ log.Debug().
139+ Str("request_id", req.RequestID).
140+ Int64("favorite_id", req.ID).
141+ Msg("handling favorite_update")
142+
143+ if req.ID <= 0 {
144+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
145+ }
146+
147+ folderID, err := folderIDFromInt64(req.FolderID)
148+ if err != nil {
149+ return NewErrorResponse(req.RequestID, err), nil
150+ }
151+
152+ favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
153+ ID: entity.FavoriteID(req.ID),
154+ Title: req.Title,
155+ FaviconURL: req.FaviconURL,
156+ FolderID: folderID,
157+ ShortcutKey: req.ShortcutKey,
158+ })
159+ if err != nil {
160+ return NewErrorResponse(req.RequestID, err), nil
161+ }
162+
163+ return NewSuccessResponse(req.RequestID, favorite), nil
164+ })
165+}
166+
167+type favoriteDeleteRequest struct {
168+ RequestID string `json:"requestId"`
169+ ID int64 `json:"id"`
170+}
171+
172+// HandleDelete handles favorite_delete messages.
173+func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler {
174+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
175+ log := logging.FromContext(ctx)
176+
177+ var req favoriteDeleteRequest
178+ if err := json.Unmarshal(payload, &req); err != nil {
179+ return NewErrorResponse("", err), nil
180+ }
181+
182+ log.Debug().
183+ Str("request_id", req.RequestID).
184+ Int64("favorite_id", req.ID).
185+ Msg("handling favorite_delete")
186+
187+ if req.ID <= 0 {
188+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
189+ }
190+
191+ if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil {
192+ return NewErrorResponse(req.RequestID, err), nil
193+ }
194+
195+ return NewSuccessResponse(req.RequestID, nil), nil
196+ })
197+}
198+
42 // setShortcutRequest is the payload for favorite_set_shortcut messages.199 // setShortcutRequest is the payload for favorite_set_shortcut messages.
43 type setShortcutRequest struct {200 type setShortcutRequest struct {
44 RequestID string `json:"requestId"`201 RequestID string `json:"requestId"`
@@ -61,6 +218,10 @@ func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler {
61 Int64("favorite_id", req.FavoriteID).218 Int64("favorite_id", req.FavoriteID).
62 Msg("handling favorite_set_shortcut")219 Msg("handling favorite_set_shortcut")
63 220
221+ if req.FavoriteID <= 0 {
222+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite id")), nil
223+ }
224+
64 if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil {225 if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil {
65 return NewErrorResponse(req.RequestID, err), nil226 return NewErrorResponse(req.RequestID, err), nil
66 }227 }
@@ -121,10 +282,13 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
121 Int64("favorite_id", req.FavoriteID).282 Int64("favorite_id", req.FavoriteID).
122 Msg("handling favorite_set_folder")283 Msg("handling favorite_set_folder")
123 284
124- var folderID *entity.FolderID285+ if req.FavoriteID <= 0 {
125- if req.FolderID != nil {286+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil
126- id := entity.FolderID(*req.FolderID)287+ }
127- folderID = &id288+
289+ folderID, err := folderIDFromInt64(req.FolderID)
290+ if err != nil {
291+ return NewErrorResponse(req.RequestID, err), nil
128 }292 }
129 293
130 if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {294 if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {
M internal/infrastructure/handlers/homepage/folders.go +13 -13
@@ -44,6 +44,7 @@ type createFolderRequest struct {
44 RequestID string `json:"requestId"`44 RequestID string `json:"requestId"`
45 Name string `json:"name"`45 Name string `json:"name"`
46 Icon *string `json:"icon"`46 Icon *string `json:"icon"`
47+ ParentID *int64 `json:"parent_id"`
47 }48 }
48 49
49 // HandleCreate handles folder_create messages.50 // HandleCreate handles folder_create messages.
@@ -61,17 +62,14 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
61 Str("name", req.Name).62 Str("name", req.Name).
62 Msg("handling folder_create")63 Msg("handling folder_create")
63 64
64- folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, nil)65+ parentID, err := folderIDFromInt64(req.ParentID)
65 if err != nil {66 if err != nil {
66 return NewErrorResponse(req.RequestID, err), nil67 return NewErrorResponse(req.RequestID, err), nil
67 }68 }
68 69
69- // Set icon if provided and persist it70+ folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID)
70- if req.Icon != nil && *req.Icon != "" {71+ if err != nil {
71- folder.Icon = *req.Icon72+ return NewErrorResponse(req.RequestID, err), nil
72- if err := h.favoritesUC.UpdateFolder(ctx, folder.ID, folder.Name, folder.Icon); err != nil {
73- return NewErrorResponse(req.RequestID, err), nil
74- }
75 }73 }
76 74
77 return NewSuccessResponse(req.RequestID, folder), nil75 return NewSuccessResponse(req.RequestID, folder), nil
@@ -117,6 +115,13 @@ type updateFolderRequest struct {
117 115
118 // HandleUpdate handles folder_update messages.116 // HandleUpdate handles folder_update messages.
119 // It delegates to the port.HomepageFavorites interface's UpdateFolder method.117 // It delegates to the port.HomepageFavorites interface's UpdateFolder method.
118+func optionalStringValue(value *string) string {
119+ if value == nil {
120+ return ""
121+ }
122+ return *value
123+}
124+
120 func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {125 func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
121 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {126 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
122 log := logging.FromContext(ctx)127 log := logging.FromContext(ctx)
@@ -132,12 +137,7 @@ func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
132 Str("name", req.Name).137 Str("name", req.Name).
133 Msg("handling folder_update")138 Msg("handling folder_update")
134 139
135- icon := ""140+ if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, optionalStringValue(req.Icon)); err != nil {
136- if req.Icon != nil {
137- icon = *req.Icon
138- }
139-
140- if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, icon); err != nil {
141 return NewErrorResponse(req.RequestID, err), nil141 return NewErrorResponse(req.RequestID, err), nil
142 }142 }
143 143
M internal/infrastructure/handlers/homepage/history.go +120 -29
@@ -3,8 +3,11 @@ package homepage
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "fmt"
7+ "strings"
6 "time"8 "time"
7 9
10+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
@@ -27,6 +30,19 @@ type timelineRequest struct {
27 Offset int `json:"offset"`30 Offset int `json:"offset"`
28 }31 }
29 32
33+type timelineByDomainRequest struct {
34+ RequestID string `json:"requestId"`
35+ Domain string `json:"domain"`
36+ Limit int `json:"limit"`
37+ Offset int `json:"offset"`
38+}
39+
40+type timelineWindowRequest struct {
41+ RequestID string `json:"requestId"`
42+ Before string `json:"before"`
43+ Domain string `json:"domain"`
44+}
45+
30 // HandleTimeline handles history_timeline messages.46 // HandleTimeline handles history_timeline messages.
31 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {47 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
32 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {48 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -42,6 +58,12 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
42 Int("limit", req.Limit).58 Int("limit", req.Limit).
43 Int("offset", req.Offset).59 Int("offset", req.Offset).
44 Msg("handling history_timeline")60 Msg("handling history_timeline")
61+ if req.Limit <= 0 {
62+ return NewErrorResponse(
63+ req.RequestID,
64+ fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"),
65+ ), nil
66+ }
45 67
46 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)68 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)
47 if err != nil {69 if err != nil {
@@ -52,6 +74,79 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
52 })74 })
53 }75 }
54 76
77+// HandleTimelineByDomain handles history_timeline_by_domain messages.
78+func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
79+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
80+ log := logging.FromContext(ctx)
81+
82+ var req timelineByDomainRequest
83+ if err := json.Unmarshal(payload, &req); err != nil {
84+ return NewErrorResponse("", err), nil
85+ }
86+
87+ req.Domain = strings.TrimSpace(req.Domain)
88+ if req.Domain == "" {
89+ return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil
90+ }
91+
92+ log.Debug().
93+ Str("request_id", req.RequestID).
94+ Str("domain", req.Domain).
95+ Int("limit", req.Limit).
96+ Int("offset", req.Offset).
97+ Msg("handling history_timeline_by_domain")
98+ if req.Limit <= 0 {
99+ return NewErrorResponse(
100+ req.RequestID,
101+ fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"),
102+ ), nil
103+ }
104+
105+ entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
106+ if err != nil {
107+ return NewErrorResponse(req.RequestID, err), nil
108+ }
109+
110+ return NewSuccessResponse(req.RequestID, entries), nil
111+ })
112+}
113+
114+// HandleTimelineWindow handles history_timeline_window messages.
115+func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
116+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
117+ log := logging.FromContext(ctx)
118+
119+ var req timelineWindowRequest
120+ if err := json.Unmarshal(payload, &req); err != nil {
121+ return NewErrorResponse("", err), nil
122+ }
123+
124+ domain := strings.TrimSpace(req.Domain)
125+
126+ var before time.Time
127+ if strings.TrimSpace(req.Before) != "" {
128+ parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))
129+ if err != nil {
130+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil
131+ }
132+ before = parsed
133+ }
134+
135+ log.Debug().
136+ Str("request_id", req.RequestID).
137+ Str("domain", domain).
138+ Time("before", before).
139+ Msg("handling history_timeline_window")
140+
141+ window, err := h.historyUC.GetRecentWindow(ctx, before, domain)
142+ if err != nil {
143+ return NewErrorResponse(req.RequestID, err), nil
144+ }
145+
146+ return NewSuccessResponse(req.RequestID, window), nil
147+ })
148+}
149+
55 // searchRequest is the payload for history_search_fts messages.150 // searchRequest is the payload for history_search_fts messages.
56 type searchRequest struct {151 type searchRequest struct {
57 RequestID string `json:"requestId"`152 RequestID string `json:"requestId"`
@@ -74,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler {
74 Str("query", req.Query).169 Str("query", req.Query).
75 Msg("handling history_search_fts")170 Msg("handling history_search_fts")
76 171
77- output, err := h.historyUC.Search(ctx, port.HistorySearchInput{172+ output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{
78 Query: req.Query,173 Query: req.Query,
79 Limit: req.Limit,174 Limit: req.Limit,
80 })175 })
@@ -137,21 +232,14 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
137 return NewErrorResponse("", err), nil232 return NewErrorResponse("", err), nil
138 }233 }
139 234
235+ req.Range = strings.TrimSpace(req.Range)
140 log.Debug().236 log.Debug().
141 Str("request_id", req.RequestID).237 Str("request_id", req.RequestID).
142 Str("range", req.Range).238 Str("range", req.Range).
143 Msg("handling history_delete_range")239 Msg("handling history_delete_range")
144 240
145- before := rangeToTime(req.Range)241+ if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
146- if before.IsZero() {242+ return NewErrorResponse(req.RequestID, err), nil
147- // "all" case - clear all
148- if err := h.historyUC.ClearAll(ctx); err != nil {
149- return NewErrorResponse(req.RequestID, err), nil
150- }
151- } else {
152- if err := h.historyUC.ClearOlderThan(ctx, before); err != nil {
153- return NewErrorResponse(req.RequestID, err), nil
154- }
155 }243 }
156 244
157 return NewSuccessResponse(req.RequestID, nil), nil245 return NewSuccessResponse(req.RequestID, nil), nil
@@ -177,24 +265,27 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
177 })265 })
178 }266 }
179 267
180-// rangeToTime converts a range string to a time.Time cutoff.268+// HandleStats handles history_stats messages.
181-// Returns zero time for "all" which means delete everything.269+func (h *HistoryHandlers) HandleStats() port.WebUIMessageHandler {
182-func rangeToTime(r string) time.Time {270+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
183- now := time.Now()271+ log := logging.FromContext(ctx)
184- switch r {272+
185- case "hour":273+ var req struct {
186- return now.Add(-time.Hour)274+ RequestID string `json:"requestId"`
187- case "day":275+ }
188- return now.AddDate(0, 0, -1)276+ if err := json.Unmarshal(payload, &req); err != nil {
189- case "week":277+ return NewErrorResponse("", err), nil
190- return now.AddDate(0, 0, -7)278+ }
191- case "month":279+
192- return now.AddDate(0, -1, 0)280+ log.Debug().Str("request_id", req.RequestID).Msg("handling history_stats")
193- case "all":281+
194- return time.Time{} // Zero time signals "delete all"282+ stats, err := h.historyUC.GetStats(ctx)
195- default:283+ if err != nil {
196- return now284+ return NewErrorResponse(req.RequestID, err), nil
197- }285+ }
286+
287+ return NewSuccessResponse(req.RequestID, stats), nil
288+ })
198 }289 }
199 290
200 // HandleAnalytics handles history_analytics messages.291 // HandleAnalytics handles history_analytics messages.
M internal/infrastructure/handlers/homepage/registry.go +6 -0
@@ -29,10 +29,13 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
29 // History handlers29 // History handlers
30 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)30 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
31 handlers["history_timeline"] = historyHandlers.HandleTimeline()31 handlers["history_timeline"] = historyHandlers.HandleTimeline()
32+ handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain()
33+ handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow()
32 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()34 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()
33 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()35 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()
34 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()36 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()
35 handlers["history_clear_all"] = historyHandlers.HandleClearAll()37 handlers["history_clear_all"] = historyHandlers.HandleClearAll()
38+ handlers["history_stats"] = historyHandlers.HandleStats()
36 handlers["history_analytics"] = historyHandlers.HandleAnalytics()39 handlers["history_analytics"] = historyHandlers.HandleAnalytics()
37 handlers["history_domain_stats"] = historyHandlers.HandleDomainStats()40 handlers["history_domain_stats"] = historyHandlers.HandleDomainStats()
38 handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain()41 handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain()
@@ -40,6 +43,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
40 // Favorites handlers43 // Favorites handlers
41 favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC)44 favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC)
42 handlers["favorite_list"] = favoritesHandlers.HandleList()45 handlers["favorite_list"] = favoritesHandlers.HandleList()
46+ handlers["favorite_create"] = favoritesHandlers.HandleCreate()
47+ handlers["favorite_update"] = favoritesHandlers.HandleUpdate()
48+ handlers["favorite_delete"] = favoritesHandlers.HandleDelete()
43 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()49 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()
44 handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()50 handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()
45 handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder()51 handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder()
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 +62 -4
@@ -14,23 +14,28 @@ import (
14 // ==================== Favorite Repository ====================14 // ==================== Favorite Repository ====================
15 15
16 type favoriteRepo struct {16 type favoriteRepo struct {
17+ db *sql.DB
17 queries *sqlc.Queries18 queries *sqlc.Queries
18 }19 }
19 20
20 // NewFavoriteRepository creates a new SQLite-backed favorite repository.21 // NewFavoriteRepository creates a new SQLite-backed favorite repository.
21 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {22 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
22- return &favoriteRepo{queries: sqlc.New(db)}23+ return &favoriteRepo{db: db, queries: sqlc.New(db)}
23 }24 }
24 25
25 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {26 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
26 log := logging.FromContext(ctx)27 log := logging.FromContext(ctx)
27- log.Debug().Str("url", fav.URL).Msg("saving favorite")28+ log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite")
28 29
29 var folderID sql.NullInt6430 var folderID sql.NullInt64
30 if fav.FolderID != nil {31 if fav.FolderID != nil {
31 folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}32 folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
32 }33 }
33 34
35+ if fav.ID > 0 {
36+ return r.updateExisting(ctx, fav, folderID)
37+ }
38+
34 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{39 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
35 Url: fav.URL,40 Url: fav.URL,
36 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},41 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
@@ -44,6 +49,43 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
44 return nil49 return nil
45 }50 }
46 51
52+func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error {
53+ log := logging.FromContext(ctx)
54+ tx, err := r.db.BeginTx(ctx, nil)
55+ if err != nil {
56+ return err
57+ }
58+ defer func() {
59+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
60+ log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error")
61+ }
62+ }()
63+
64+ shortcutKey := sql.NullInt64{}
65+ if fav.ShortcutKey != nil {
66+ shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true}
67+ }
68+
69+ queries := r.queries.WithTx(tx)
70+ if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
71+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
72+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
73+ ID: int64(fav.ID),
74+ }); err != nil {
75+ return err
76+ }
77+ if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
78+ return err
79+ }
80+ if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
81+ ShortcutKey: shortcutKey,
82+ ID: int64(fav.ID),
83+ }); err != nil {
84+ return err
85+ }
86+ return tx.Commit()
87+}
88+
47 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {89 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
48 row, err := r.queries.GetFavoriteByID(ctx, int64(id))90 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
49 if err != nil {91 if err != nil {
@@ -175,13 +217,21 @@ func NewFolderRepository(db *sql.DB) repository.FolderRepository {
175 217
176 func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {218 func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
177 log := logging.FromContext(ctx)219 log := logging.FromContext(ctx)
178- log.Debug().Str("name", folder.Name).Msg("saving folder")220+ log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder")
179 221
180 var parentID sql.NullInt64222 var parentID sql.NullInt64
181 if folder.ParentID != nil {223 if folder.ParentID != nil {
182 parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}224 parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}
183 }225 }
184 226
227+ if folder.ID > 0 {
228+ return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{
229+ Name: folder.Name,
230+ Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
231+ ID: int64(folder.ID),
232+ })
233+ }
234+
185 row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{235 row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{
186 Name: folder.Name,236 Name: folder.Name,
187 Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},237 Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
@@ -275,7 +325,15 @@ func NewTagRepository(db *sql.DB) repository.TagRepository {
275 325
276 func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {326 func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {
277 log := logging.FromContext(ctx)327 log := logging.FromContext(ctx)
278- log.Debug().Str("name", tag.Name).Msg("saving tag")328+ log.Debug().Str("name", tag.Name).Int64("id", int64(tag.ID)).Msg("saving tag")
329+
330+ if tag.ID > 0 {
331+ return r.queries.UpdateTag(ctx, sqlc.UpdateTagParams{
332+ Name: tag.Name,
333+ Color: tag.Color,
334+ ID: int64(tag.ID),
335+ })
336+ }
279 337
280 row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{338 row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{
281 Name: tag.Name,339 Name: tag.Name,
M internal/infrastructure/persistence/sqlite/history_repo.go +106 -30
@@ -61,10 +61,12 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro
61 log := logging.FromContext(ctx)61 log := logging.FromContext(ctx)
62 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")62 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
63 63
64+ domain := domainurl.CanonicalDomain(entry.URL)
64 err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{65 err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
65 Url: entry.URL,66 Url: entry.URL,
66 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},67 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
67 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},68 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
69+ Domain: sql.NullString{String: domain, Valid: domain != ""},
68 })70 })
69 if err != nil {71 if err != nil {
70 return err72 return err
@@ -481,6 +483,13 @@ func isExpectedFTSError(err error) bool {
481 }483 }
482 484
483 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {485 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
486+ if limit <= 0 {
487+ rows, err := r.queries.GetAllRecentHistory(ctx)
488+ if err != nil {
489+ return nil, err
490+ }
491+ return historyEntriesFromRows(rows), nil
492+ }
484 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{493 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
485 Limit: int64(limit),494 Limit: int64(limit),
486 Offset: int64(offset),495 Offset: int64(offset),
@@ -488,12 +497,83 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
488 if err != nil {497 if err != nil {
489 return nil, err498 return nil, err
490 }499 }
500+ return historyEntriesFromRows(rows), nil
501+}
491 502
492- entries := make([]*entity.HistoryEntry, len(rows))503+func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
493- for i := range rows {504+ rawDomain := domain
494- entries[i] = historyFromRow(rows[i])505+ domain = domainurl.CanonicalDomain(domain)
506+ if domain == "" {
507+ return nil, fmt.Errorf("history domain is required: %q", rawDomain)
508+ }
509+ if limit <= 0 {
510+ rows, err := r.queries.GetAllRecentHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
511+ if err != nil {
512+ return nil, err
513+ }
514+ return historyEntriesFromRows(rows), nil
515+ }
516+ rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
517+ Domain: sql.NullString{String: domain, Valid: true},
518+ Limit: int64(limit),
519+ Offset: int64(offset),
520+ })
521+ if err != nil {
522+ return nil, err
523+ }
524+ return historyEntriesFromRows(rows), nil
525+}
526+
527+func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
528+ rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{
529+ Before: sql.NullTime{Time: before, Valid: true},
530+ After: sql.NullTime{Time: after, Valid: true},
531+ })
532+ if err != nil {
533+ return nil, err
534+ }
535+ return historyEntriesFromRows(rows), nil
536+}
537+
538+func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) {
539+ rawDomain := domain
540+ domain = domainurl.CanonicalDomain(domain)
541+ if domain == "" {
542+ return nil, fmt.Errorf("history domain is required: %q", rawDomain)
543+ }
544+ rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{
545+ Domain: sql.NullString{String: domain, Valid: true},
546+ Before: sql.NullTime{Time: before, Valid: true},
547+ After: sql.NullTime{Time: after, Valid: true},
548+ })
549+ if err != nil {
550+ return nil, err
551+ }
552+ return historyEntriesFromRows(rows), nil
553+}
554+
555+func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
556+ value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true})
557+ if err != nil {
558+ return false, err
495 }559 }
496- return entries, nil560+ return value != 0, nil
561+}
562+
563+func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
564+ rawDomain := domain
565+ domain = domainurl.CanonicalDomain(domain)
566+ if domain == "" {
567+ return false, fmt.Errorf("history domain is required: %q", rawDomain)
568+ }
569+ value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{
570+ Domain: sql.NullString{String: domain, Valid: true},
571+ Before: sql.NullTime{Time: before, Valid: true},
572+ })
573+ if err != nil {
574+ return false, err
575+ }
576+ return value != 0, nil
497 }577 }
498 578
499 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {579 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -507,11 +587,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H
507 return nil, err587 return nil, err
508 }588 }
509 589
510- entries := make([]*entity.HistoryEntry, len(rows))590+ return historyEntriesFromRows(rows), nil
511- for i := range rows {
512- entries[i] = historyFromRow(rows[i])
513- }
514- return entries, nil
515 }591 }
516 592
517 func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {593 func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -525,11 +601,7 @@ func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.H
525 return nil, err601 return nil, err
526 }602 }
527 603
528- entries := make([]*entity.HistoryEntry, len(rows))604+ return historyEntriesFromRows(rows), nil
529- for i := range rows {
530- entries[i] = historyFromRow(rows[i])
531- }
532- return entries, nil
533 }605 }
534 606
535 func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {607 func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -538,11 +610,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor
538 return nil, err610 return nil, err
539 }611 }
540 612
541- entries := make([]*entity.HistoryEntry, len(rows))613+ return historyEntriesFromRows(rows), nil
542- for i := range rows {
543- entries[i] = historyFromRow(rows[i])
544- }
545- return entries, nil
546 }614 }
547 615
548 func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {616 func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -551,11 +619,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
551 return nil, err619 return nil, err
552 }620 }
553 621
554- entries := make([]*entity.HistoryEntry, len(rows))622+ return historyEntriesFromRows(rows), nil
555- for i := range rows {
556- entries[i] = historyFromRow(rows[i])
557- }
558- return entries, nil
559 }623 }
560 624
561 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {625 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
@@ -588,17 +652,21 @@ func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) err
588 return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})652 return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
589 }653 }
590 654
655+func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error {
656+ return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true})
657+}
658+
591 func (r *historyRepo) DeleteAll(ctx context.Context) error {659 func (r *historyRepo) DeleteAll(ctx context.Context) error {
592 return r.queries.DeleteAllHistory(ctx)660 return r.queries.DeleteAllHistory(ctx)
593 }661 }
594 662
595 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {663 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
596- return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{664+ rawDomain := domain
597- Column1: sql.NullString{String: domain, Valid: true},665+ domain = domainurl.CanonicalDomain(domain)
598- Column2: sql.NullString{String: domain, Valid: true},666+ if domain == "" {
599- Column3: sql.NullString{String: domain, Valid: true},667+ return fmt.Errorf("history domain is required: %q", rawDomain)
600- Column4: sql.NullString{String: domain, Valid: true},668+ }
601- })669+ return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
602 }670 }
603 671
604 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {672 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
@@ -694,6 +762,14 @@ func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([
694 return counts, nil762 return counts, nil
695 }763 }
696 764
765+func historyEntriesFromRows(rows []sqlc.History) []*entity.HistoryEntry {
766+ entries := make([]*entity.HistoryEntry, len(rows))
767+ for i := range rows {
768+ entries[i] = historyFromRow(rows[i])
769+ }
770+ return entries
771+}
772+
697 func historyFromRow(row sqlc.History) *entity.HistoryEntry {773 func historyFromRow(row sqlc.History) *entity.HistoryEntry {
698 return &entity.HistoryEntry{774 return &entity.HistoryEntry{
699 ID: row.ID,775 ID: row.ID,
M internal/infrastructure/persistence/sqlite/history_repo_test.go +58 -0
@@ -191,6 +191,10 @@ func TestHistoryRepository_CRUD(t *testing.T) {
191 require.NoError(t, err)191 require.NoError(t, err)
192 require.Len(t, recent, 1)192 require.Len(t, recent, 1)
193 193
194+ allRecent, err := repo.GetRecent(ctx, 0, 0)
195+ require.NoError(t, err)
196+ require.Len(t, allRecent, 1)
197+
194 // Delete198 // Delete
195 require.NoError(t, repo.Delete(ctx, found.ID))199 require.NoError(t, repo.Delete(ctx, found.ID))
196 200
@@ -832,3 +836,57 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
832 require.NoError(t, err)836 require.NoError(t, err)
833 assert.Empty(t, results)837 assert.Empty(t, results)
834 }838 }
839+
840+func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) {
841+ ctx := historyTestCtx()
842+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
843+
844+ db, err := sqlite.NewConnection(ctx, dbPath)
845+ require.NoError(t, err)
846+ t.Cleanup(func() { _ = db.Close() })
847+
848+ repo := sqlite.NewHistoryRepository(db)
849+
850+ entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0)
851+ require.Error(t, err)
852+ assert.Nil(t, entries)
853+ assert.Error(t, repo.DeleteByDomain(ctx, "/path"))
854+}
855+
856+func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
857+ ctx := historyTestCtx()
858+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
859+
860+ db, err := sqlite.NewConnection(ctx, dbPath)
861+ require.NoError(t, err)
862+ t.Cleanup(func() { _ = db.Close() })
863+
864+ repo := sqlite.NewHistoryRepository(db)
865+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"}))
866+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"}))
867+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"}))
868+
869+ exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0)
870+ require.NoError(t, err)
871+ require.Len(t, exampleEntries, 2)
872+ for _, entry := range exampleEntries {
873+ assert.Contains(t, entry.URL, "example.com")
874+ }
875+
876+ stats, err := repo.GetDomainStats(ctx, 10)
877+ require.NoError(t, err)
878+ require.NotEmpty(t, stats)
879+ assert.Equal(t, "example.com", stats[0].Domain)
880+ assert.Equal(t, int64(2), stats[0].PageCount)
881+
882+ require.NoError(t, repo.DeleteByDomain(ctx, "example.com"))
883+
884+ exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0)
885+ require.NoError(t, err)
886+ assert.Empty(t, exampleEntries)
887+
888+ remaining, err := repo.GetAllRecentHistory(ctx)
889+ require.NoError(t, err)
890+ require.Len(t, remaining, 1)
891+ assert.Equal(t, "https://other.test", remaining[0].URL)
892+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +47 -0
@@ -149,6 +149,46 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
149 return r.repo.GetRecent(ctx, limit, offset)149 return r.repo.GetRecent(ctx, limit, offset)
150 }150 }
151 151
152+func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
153+ if err := r.init(ctx); err != nil {
154+ return nil, err
155+ }
156+ return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157+}
158+
159+func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
160+ if err := r.init(ctx); err != nil {
161+ return nil, err
162+ }
163+ return r.repo.GetRecentWindow(ctx, before, after)
164+}
165+
166+func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167+ ctx context.Context,
168+ domain string,
169+ before time.Time,
170+ after time.Time,
171+) ([]*entity.HistoryEntry, error) {
172+ if err := r.init(ctx); err != nil {
173+ return nil, err
174+ }
175+ return r.repo.GetRecentWindowByDomain(ctx, domain, before, after)
176+}
177+
178+func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
179+ if err := r.init(ctx); err != nil {
180+ return false, err
181+ }
182+ return r.repo.HasEntriesBefore(ctx, before)
183+}
184+
185+func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
186+ if err := r.init(ctx); err != nil {
187+ return false, err
188+ }
189+ return r.repo.HasEntriesByDomainBefore(ctx, domain, before)
190+}
191+
152 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {192 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
153 if err := r.init(ctx); err != nil {193 if err := r.init(ctx); err != nil {
154 return nil, err194 return nil, err
@@ -223,6 +263,13 @@ func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time
223 return r.repo.DeleteOlderThan(ctx, before)263 return r.repo.DeleteOlderThan(ctx, before)
224 }264 }
225 265
266+func (r *LazyHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error {
267+ if err := r.init(ctx); err != nil {
268+ return err
269+ }
270+ return r.repo.DeleteSince(ctx, since)
271+}
272+
226 func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {273 func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
227 if err := r.init(ctx); err != nil {274 if err := r.init(ctx); err != nil {
228 return err275 return err
A internal/infrastructure/persistence/sqlite/migrations/008_history_domain.sql +90 -0
@@ -0,0 +1,90 @@
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+
35+-- Rebuild instead of ALTER TABLE DROP COLUMN so rollback works on SQLite
36+-- versions before 3.35. Preserve the base history schema and indexes/triggers.
37+DROP TRIGGER IF EXISTS history_fts_update;
38+DROP TRIGGER IF EXISTS history_fts_delete;
39+DROP TRIGGER IF EXISTS history_fts_insert;
40+DROP TABLE IF EXISTS history_fts;
41+
42+CREATE TABLE history_008_down (
43+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44+ url TEXT NOT NULL UNIQUE,
45+ title TEXT,
46+ favicon_url TEXT,
47+ visit_count INTEGER DEFAULT 1,
48+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
49+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
50+);
51+
52+INSERT INTO history_008_down (id, url, title, favicon_url, visit_count, last_visited, created_at)
53+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at
54+FROM history;
55+
56+DROP TABLE history;
57+ALTER TABLE history_008_down RENAME TO history;
58+
59+CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
60+CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
61+CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited ON history(visit_count DESC, last_visited DESC);
62+
63+CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
64+ url,
65+ title,
66+ content='history',
67+ content_rowid='id'
68+);
69+
70+-- +goose StatementBegin
71+CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN
72+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
73+END;
74+-- +goose StatementEnd
75+
76+-- +goose StatementBegin
77+CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN
78+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
79+END;
80+-- +goose StatementEnd
81+
82+-- +goose StatementBegin
83+CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN
84+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
85+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
86+END;
87+-- +goose StatementEnd
88+
89+INSERT INTO history_fts(rowid, url, title)
90+SELECT id, url, title FROM history;
M internal/infrastructure/persistence/sqlite/queries/history.sql +66 -10
@@ -1,18 +1,46 @@
1 -- name: GetRecentHistory :many1 -- name: GetRecentHistory :many
2 SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;2 SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
3 3
4+-- name: GetRecentHistoryByDomain :many
5+SELECT * FROM history
6+WHERE domain = @domain
7+ORDER BY last_visited DESC
8+LIMIT @limit OFFSET @offset;
9+
10+-- name: GetAllRecentHistoryByDomain :many
11+SELECT * FROM history
12+WHERE domain = @domain
13+ORDER BY last_visited DESC;
14+
15+-- name: GetRecentHistoryWindow :many
16+SELECT * FROM history
17+WHERE last_visited < @before AND last_visited >= @after
18+ORDER BY last_visited DESC;
19+
20+-- name: GetRecentHistoryWindowByDomain :many
21+SELECT * FROM history
22+WHERE domain = @domain AND last_visited < @before AND last_visited >= @after
23+ORDER BY last_visited DESC;
24+
25+-- name: HasHistoryBefore :one
26+SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1);
27+
28+-- name: HasHistoryByDomainBefore :one
29+SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1);
30+
4 -- name: GetHistoryByURL :one31 -- name: GetHistoryByURL :one
5 SELECT * FROM history WHERE url = ? LIMIT 1;32 SELECT * FROM history WHERE url = ? LIMIT 1;
6 33
7 -- name: UpsertHistory :exec34 -- name: UpsertHistory :exec
8-INSERT INTO history (url, title, favicon_url)35+INSERT INTO history (url, title, favicon_url, domain)
9-VALUES (?, ?, ?)36+VALUES (?, ?, ?, ?)
10 ON CONFLICT(url)37 ON CONFLICT(url)
11 DO UPDATE SET38 DO UPDATE SET
12 visit_count = visit_count + 1,39 visit_count = visit_count + 1,
13 last_visited = CURRENT_TIMESTAMP,40 last_visited = CURRENT_TIMESTAMP,
14 title = COALESCE(EXCLUDED.title, history.title),41 title = COALESCE(EXCLUDED.title, history.title),
15- favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url);42+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
43+ domain = COALESCE(EXCLUDED.domain, history.domain);
16 44
17 -- name: IncrementVisitCount :exec45 -- name: IncrementVisitCount :exec
18 UPDATE history46 UPDATE history
@@ -36,26 +64,54 @@ DELETE FROM history WHERE id = ?;
36 -- name: DeleteHistoryOlderThan :exec64 -- name: DeleteHistoryOlderThan :exec
37 DELETE FROM history WHERE last_visited < ?;65 DELETE FROM history WHERE last_visited < ?;
38 66
67+-- name: DeleteHistorySince :exec
68+DELETE FROM history WHERE last_visited >= ?;
69+
39 -- name: DeleteAllHistory :exec70 -- name: DeleteAllHistory :exec
40 DELETE FROM history;71 DELETE FROM history;
41 72
42 -- name: GetHistoryStats :one73 -- name: GetHistoryStats :one
43-SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;74+SELECT
75+ COUNT(*) as total_entries,
76+ COALESCE(SUM(visit_count), 0) as total_visits,
77+ COUNT(DISTINCT date(last_visited)) as unique_days
78+FROM history;
44 79
45 -- name: GetDomainStats :many80 -- name: GetDomainStats :many
46-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 ?;81+SELECT
82+ COALESCE(domain, '') as domain,
83+ COUNT(*) as page_count,
84+ SUM(visit_count) as total_visits,
85+ MAX(last_visited) as last_visit
86+FROM history
87+WHERE domain IS NOT NULL AND domain != ''
88+GROUP BY domain
89+ORDER BY total_visits DESC
90+LIMIT ?;
47 91
48 -- name: GetHourlyDistribution :many92 -- name: GetHourlyDistribution :many
49-SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;93+SELECT
94+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
95+ COUNT(*) as visit_count
96+FROM history
97+GROUP BY hour
98+ORDER BY hour;
50 99
51 -- name: GetDailyVisitCount :many100 -- name: GetDailyVisitCount :many
52-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;101+SELECT
102+ date(last_visited) as day,
103+ COUNT(*) as entries,
104+ SUM(visit_count) as visits
105+FROM history
106+WHERE last_visited >= date('now', ?)
107+GROUP BY day
108+ORDER BY day ASC;
53 109
54 -- name: DeleteHistoryByDomain :exec110 -- name: DeleteHistoryByDomain :exec
55-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;111+DELETE FROM history WHERE domain = @domain;
56 112
57 -- name: SearchHistoryFTSUrl :many113 -- name: SearchHistoryFTSUrl :many
58-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at114+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
59 FROM history_fts fts115 FROM history_fts fts
60 JOIN history h ON fts.rowid = h.id116 JOIN history h ON fts.rowid = h.id
61 WHERE fts.url MATCH @query117 WHERE fts.url MATCH @query
@@ -78,7 +134,7 @@ ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
78 LIMIT @limit;134 LIMIT @limit;
79 135
80 -- name: SearchHistoryFTSTitle :many136 -- name: SearchHistoryFTSTitle :many
81-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at137+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
82 FROM history_fts fts138 FROM history_fts fts
83 JOIN history h ON fts.rowid = h.id139 JOIN history h ON fts.rowid = h.id
84 WHERE fts.title MATCH @query140 WHERE fts.title MATCH @query
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +267 -32
@@ -35,23 +35,11 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
35 }35 }
36 36
37 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec37 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
38-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?38+DELETE FROM history WHERE domain = ?1
39 `39 `
40 40
41-type DeleteHistoryByDomainParams struct {41+func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error {
42- Column1 sql.NullString `json:"column_1"`42+ _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain, domain)
43- Column2 sql.NullString `json:"column_2"`
44- Column3 sql.NullString `json:"column_3"`
45- Column4 sql.NullString `json:"column_4"`
46-}
47-
48-func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error {
49- _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain,
50- arg.Column1,
51- arg.Column2,
52- arg.Column3,
53- arg.Column4,
54- )
55 return err43 return err
56 }44 }
57 45
@@ -73,8 +61,17 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
73 return err61 return err
74 }62 }
75 63
64+const DeleteHistorySince = `-- name: DeleteHistorySince :exec
65+DELETE FROM history WHERE last_visited >= ?
66+`
67+
68+func (q *Queries) DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error {
69+ _, err := q.db.ExecContext(ctx, DeleteHistorySince, lastVisited)
70+ return err
71+}
72+
76 const GetAllMostVisited = `-- name: GetAllMostVisited :many73 const GetAllMostVisited = `-- name: GetAllMostVisited :many
77-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history74+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
78 ORDER BY visit_count DESC, last_visited DESC75 ORDER BY visit_count DESC, last_visited DESC
79 `76 `
80 77
@@ -95,6 +92,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
95 &i.VisitCount,92 &i.VisitCount,
96 &i.LastVisited,93 &i.LastVisited,
97 &i.CreatedAt,94 &i.CreatedAt,
95+ &i.Domain,
98 ); err != nil {96 ); err != nil {
99 return nil, err97 return nil, err
100 }98 }
@@ -110,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
110 }108 }
111 109
112 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
113-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history111+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
114 ORDER BY last_visited DESC112 ORDER BY last_visited DESC
115 `113 `
116 114
@@ -131,6 +129,45 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
131 &i.VisitCount,129 &i.VisitCount,
132 &i.LastVisited,130 &i.LastVisited,
133 &i.CreatedAt,131 &i.CreatedAt,
132+ &i.Domain,
133+ ); err != nil {
134+ return nil, err
135+ }
136+ items = append(items, i)
137+ }
138+ if err := rows.Close(); err != nil {
139+ return nil, err
140+ }
141+ if err := rows.Err(); err != nil {
142+ return nil, err
143+ }
144+ return items, nil
145+}
146+
147+const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many
148+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
149+WHERE domain = ?1
150+ORDER BY last_visited DESC
151+`
152+
153+func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) {
154+ rows, err := q.db.QueryContext(ctx, GetAllRecentHistoryByDomain, domain)
155+ if err != nil {
156+ return nil, err
157+ }
158+ defer rows.Close()
159+ items := []History{}
160+ for rows.Next() {
161+ var i History
162+ if err := rows.Scan(
163+ &i.ID,
164+ &i.Url,
165+ &i.Title,
166+ &i.FaviconUrl,
167+ &i.VisitCount,
168+ &i.LastVisited,
169+ &i.CreatedAt,
170+ &i.Domain,
134 ); err != nil {171 ); err != nil {
135 return nil, err172 return nil, err
136 }173 }
@@ -146,7 +183,14 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
146 }183 }
147 184
148 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many185 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
149-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 ASC186+SELECT
187+ date(last_visited) as day,
188+ COUNT(*) as entries,
189+ SUM(visit_count) as visits
190+FROM history
191+WHERE last_visited >= date('now', ?)
192+GROUP BY day
193+ORDER BY day ASC
150 `194 `
151 195
152 type GetDailyVisitCountRow struct {196 type GetDailyVisitCountRow struct {
@@ -179,7 +223,16 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G
179 }223 }
180 224
181 const GetDomainStats = `-- name: GetDomainStats :many225 const GetDomainStats = `-- name: GetDomainStats :many
182-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 ?226+SELECT
227+ COALESCE(domain, '') as domain,
228+ COUNT(*) as page_count,
229+ SUM(visit_count) as total_visits,
230+ MAX(last_visited) as last_visit
231+FROM history
232+WHERE domain IS NOT NULL AND domain != ''
233+GROUP BY domain
234+ORDER BY total_visits DESC
235+LIMIT ?
183 `236 `
184 237
185 type GetDomainStatsRow struct {238 type GetDomainStatsRow struct {
@@ -218,7 +271,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS
218 }271 }
219 272
220 const GetHistoryByURL = `-- name: GetHistoryByURL :one273 const GetHistoryByURL = `-- name: GetHistoryByURL :one
221-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1274+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history WHERE url = ? LIMIT 1
222 `275 `
223 276
224 func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {277 func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {
@@ -232,12 +285,17 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
232 &i.VisitCount,285 &i.VisitCount,
233 &i.LastVisited,286 &i.LastVisited,
234 &i.CreatedAt,287 &i.CreatedAt,
288+ &i.Domain,
235 )289 )
236 return i, err290 return i, err
237 }291 }
238 292
239 const GetHistoryStats = `-- name: GetHistoryStats :one293 const GetHistoryStats = `-- name: GetHistoryStats :one
240-SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history294+SELECT
295+ COUNT(*) as total_entries,
296+ COALESCE(SUM(visit_count), 0) as total_visits,
297+ COUNT(DISTINCT date(last_visited)) as unique_days
298+FROM history
241 `299 `
242 300
243 type GetHistoryStatsRow struct {301 type GetHistoryStatsRow struct {
@@ -254,7 +312,12 @@ func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, erro
254 }312 }
255 313
256 const GetHourlyDistribution = `-- name: GetHourlyDistribution :many314 const GetHourlyDistribution = `-- name: GetHourlyDistribution :many
257-SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour315+SELECT
316+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
317+ COUNT(*) as visit_count
318+FROM history
319+GROUP BY hour
320+ORDER BY hour
258 `321 `
259 322
260 type GetHourlyDistributionRow struct {323 type GetHourlyDistributionRow struct {
@@ -286,7 +349,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
286 }349 }
287 350
288 const GetMostVisited = `-- name: GetMostVisited :many351 const GetMostVisited = `-- name: GetMostVisited :many
289-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history352+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
290 WHERE last_visited >= datetime('now', ?)353 WHERE last_visited >= datetime('now', ?)
291 ORDER BY visit_count DESC, last_visited DESC354 ORDER BY visit_count DESC, last_visited DESC
292 `355 `
@@ -308,6 +371,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
308 &i.VisitCount,371 &i.VisitCount,
309 &i.LastVisited,372 &i.LastVisited,
310 &i.CreatedAt,373 &i.CreatedAt,
374+ &i.Domain,
311 ); err != nil {375 ); err != nil {
312 return nil, err376 return nil, err
313 }377 }
@@ -323,7 +387,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
323 }387 }
324 388
325 const GetRecentHistory = `-- name: GetRecentHistory :many389 const GetRecentHistory = `-- name: GetRecentHistory :many
326-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?390+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
327 `391 `
328 392
329 type GetRecentHistoryParams struct {393 type GetRecentHistoryParams struct {
@@ -348,6 +412,52 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
348 &i.VisitCount,412 &i.VisitCount,
349 &i.LastVisited,413 &i.LastVisited,
350 &i.CreatedAt,414 &i.CreatedAt,
415+ &i.Domain,
416+ ); err != nil {
417+ return nil, err
418+ }
419+ items = append(items, i)
420+ }
421+ if err := rows.Close(); err != nil {
422+ return nil, err
423+ }
424+ if err := rows.Err(); err != nil {
425+ return nil, err
426+ }
427+ return items, nil
428+}
429+
430+const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
431+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
432+WHERE domain = ?1
433+ORDER BY last_visited DESC
434+LIMIT ?3 OFFSET ?2
435+`
436+
437+type GetRecentHistoryByDomainParams struct {
438+ Domain sql.NullString `json:"domain"`
439+ Offset int64 `json:"offset"`
440+ Limit int64 `json:"limit"`
441+}
442+
443+func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) {
444+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryByDomain, arg.Domain, arg.Offset, arg.Limit)
445+ if err != nil {
446+ return nil, err
447+ }
448+ defer rows.Close()
449+ items := []History{}
450+ for rows.Next() {
451+ var i History
452+ if err := rows.Scan(
453+ &i.ID,
454+ &i.Url,
455+ &i.Title,
456+ &i.FaviconUrl,
457+ &i.VisitCount,
458+ &i.LastVisited,
459+ &i.CreatedAt,
460+ &i.Domain,
351 ); err != nil {461 ); err != nil {
352 return nil, err462 return nil, err
353 }463 }
@@ -363,7 +473,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
363 }473 }
364 474
365 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many475 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
366-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history476+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
367 WHERE last_visited >= datetime('now', ?)477 WHERE last_visited >= datetime('now', ?)
368 ORDER BY last_visited DESC478 ORDER BY last_visited DESC
369 `479 `
@@ -385,6 +495,94 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
385 &i.VisitCount,495 &i.VisitCount,
386 &i.LastVisited,496 &i.LastVisited,
387 &i.CreatedAt,497 &i.CreatedAt,
498+ &i.Domain,
499+ ); err != nil {
500+ return nil, err
501+ }
502+ items = append(items, i)
503+ }
504+ if err := rows.Close(); err != nil {
505+ return nil, err
506+ }
507+ if err := rows.Err(); err != nil {
508+ return nil, err
509+ }
510+ return items, nil
511+}
512+
513+const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
514+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
515+WHERE last_visited < ?1 AND last_visited >= ?2
516+ORDER BY last_visited DESC
517+`
518+
519+type GetRecentHistoryWindowParams struct {
520+ Before sql.NullTime `json:"before"`
521+ After sql.NullTime `json:"after"`
522+}
523+
524+func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) {
525+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After)
526+ if err != nil {
527+ return nil, err
528+ }
529+ defer rows.Close()
530+ items := []History{}
531+ for rows.Next() {
532+ var i History
533+ if err := rows.Scan(
534+ &i.ID,
535+ &i.Url,
536+ &i.Title,
537+ &i.FaviconUrl,
538+ &i.VisitCount,
539+ &i.LastVisited,
540+ &i.CreatedAt,
541+ &i.Domain,
542+ ); err != nil {
543+ return nil, err
544+ }
545+ items = append(items, i)
546+ }
547+ if err := rows.Close(); err != nil {
548+ return nil, err
549+ }
550+ if err := rows.Err(); err != nil {
551+ return nil, err
552+ }
553+ return items, nil
554+}
555+
556+const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many
557+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
558+WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3
559+ORDER BY last_visited DESC
560+`
561+
562+type GetRecentHistoryWindowByDomainParams struct {
563+ Domain sql.NullString `json:"domain"`
564+ Before sql.NullTime `json:"before"`
565+ After sql.NullTime `json:"after"`
566+}
567+
568+func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) {
569+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After)
570+ if err != nil {
571+ return nil, err
572+ }
573+ defer rows.Close()
574+ items := []History{}
575+ for rows.Next() {
576+ var i History
577+ if err := rows.Scan(
578+ &i.ID,
579+ &i.Url,
580+ &i.Title,
581+ &i.FaviconUrl,
582+ &i.VisitCount,
583+ &i.LastVisited,
584+ &i.CreatedAt,
585+ &i.Domain,
388 ); err != nil {586 ); err != nil {
389 return nil, err587 return nil, err
390 }588 }
@@ -399,6 +597,33 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
399 return items, nil597 return items, nil
400 }598 }
401 599
600+const HasHistoryBefore = `-- name: HasHistoryBefore :one
601+SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1)
602+`
603+
604+func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) {
605+ row := q.db.QueryRowContext(ctx, HasHistoryBefore, before)
606+ var column_1 int64
607+ err := row.Scan(&column_1)
608+ return column_1, err
609+}
610+
611+const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one
612+SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1)
613+`
614+
615+type HasHistoryByDomainBeforeParams struct {
616+ Domain sql.NullString `json:"domain"`
617+ Before sql.NullTime `json:"before"`
618+}
619+
620+func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) {
621+ row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before)
622+ var column_1 int64
623+ err := row.Scan(&column_1)
624+ return column_1, err
625+}
626+
402 const IncrementVisitCount = `-- name: IncrementVisitCount :exec627 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
403 UPDATE history628 UPDATE history
404 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP629 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
@@ -427,7 +652,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV
427 }652 }
428 653
429 const SearchHistory = `-- name: SearchHistory :many654 const SearchHistory = `-- name: SearchHistory :many
430-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history655+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
431 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'656 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
432 ORDER BY visit_count DESC, last_visited DESC657 ORDER BY visit_count DESC, last_visited DESC
433 LIMIT ?658 LIMIT ?
@@ -456,6 +681,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
456 &i.VisitCount,681 &i.VisitCount,
457 &i.LastVisited,682 &i.LastVisited,
458 &i.CreatedAt,683 &i.CreatedAt,
684+ &i.Domain,
459 ); err != nil {685 ); err != nil {
460 return nil, err686 return nil, err
461 }687 }
@@ -471,7 +697,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
471 }697 }
472 698
473 const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many699 const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
474-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at700+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
475 FROM history_fts fts701 FROM history_fts fts
476 JOIN history h ON fts.rowid = h.id702 JOIN history h ON fts.rowid = h.id
477 WHERE fts.title MATCH ?1703 WHERE fts.title MATCH ?1
@@ -501,6 +727,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
501 &i.VisitCount,727 &i.VisitCount,
502 &i.LastVisited,728 &i.LastVisited,
503 &i.CreatedAt,729 &i.CreatedAt,
730+ &i.Domain,
504 ); err != nil {731 ); err != nil {
505 return nil, err732 return nil, err
506 }733 }
@@ -516,7 +743,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
516 }743 }
517 744
518 const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many745 const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
519-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at746+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
520 FROM history_fts fts747 FROM history_fts fts
521 JOIN history h ON fts.rowid = h.id748 JOIN history h ON fts.rowid = h.id
522 WHERE fts.url MATCH ?1749 WHERE fts.url MATCH ?1
@@ -546,6 +773,7 @@ func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSU
546 &i.VisitCount,773 &i.VisitCount,
547 &i.LastVisited,774 &i.LastVisited,
548 &i.CreatedAt,775 &i.CreatedAt,
776+ &i.Domain,
549 ); err != nil {777 ); err != nil {
550 return nil, err778 return nil, err
551 }779 }
@@ -626,23 +854,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se
626 }854 }
627 855
628 const UpsertHistory = `-- name: UpsertHistory :exec856 const UpsertHistory = `-- name: UpsertHistory :exec
629-INSERT INTO history (url, title, favicon_url)857+INSERT INTO history (url, title, favicon_url, domain)
630-VALUES (?, ?, ?)858+VALUES (?, ?, ?, ?)
631 ON CONFLICT(url)859 ON CONFLICT(url)
632 DO UPDATE SET860 DO UPDATE SET
633 visit_count = visit_count + 1,861 visit_count = visit_count + 1,
634 last_visited = CURRENT_TIMESTAMP,862 last_visited = CURRENT_TIMESTAMP,
635 title = COALESCE(EXCLUDED.title, history.title),863 title = COALESCE(EXCLUDED.title, history.title),
636- favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url)864+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
865+ domain = COALESCE(EXCLUDED.domain, history.domain)
637 `866 `
638 867
639 type UpsertHistoryParams struct {868 type UpsertHistoryParams struct {
640 Url string `json:"url"`869 Url string `json:"url"`
641 Title sql.NullString `json:"title"`870 Title sql.NullString `json:"title"`
642 FaviconUrl sql.NullString `json:"favicon_url"`871 FaviconUrl sql.NullString `json:"favicon_url"`
872+ Domain sql.NullString `json:"domain"`
643 }873 }
644 874
645 func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {875 func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {
646- _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl)876+ _, err := q.db.ExecContext(ctx, UpsertHistory,
877+ arg.Url,
878+ arg.Title,
879+ arg.FaviconUrl,
880+ arg.Domain,
881+ )
647 return err882 return err
648 }883 }
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
@@ -56,6 +56,7 @@ type History struct {
56 VisitCount sql.NullInt64 `json:"visit_count"`56 VisitCount sql.NullInt64 `json:"visit_count"`
57 LastVisited sql.NullTime `json:"last_visited"`57 LastVisited sql.NullTime `json:"last_visited"`
58 CreatedAt sql.NullTime `json:"created_at"`58 CreatedAt sql.NullTime `json:"created_at"`
59+ Domain sql.NullString `json:"domain"`
59 }60 }
60 61
61 type HistoryFt struct {62 type HistoryFt struct {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +8 -1
@@ -21,9 +21,10 @@ type Querier interface {
21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
22 DeleteFavorite(ctx context.Context, id int64) error22 DeleteFavorite(ctx context.Context, id int64) error
23 DeleteFolder(ctx context.Context, id int64) error23 DeleteFolder(ctx context.Context, id int64) error
24- DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error24+ DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
25 DeleteHistoryByID(ctx context.Context, id int64) error25 DeleteHistoryByID(ctx context.Context, id int64) error
26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error26 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
27+ DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error
27 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.28 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
28 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)29 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
29 DeletePermission(ctx context.Context, arg DeletePermissionParams) error30 DeletePermission(ctx context.Context, arg DeletePermissionParams) error
@@ -36,6 +37,7 @@ type Querier interface {
36 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)37 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
37 GetAllMostVisited(ctx context.Context) ([]History, error)38 GetAllMostVisited(ctx context.Context) ([]History, error)
38 GetAllRecentHistory(ctx context.Context) ([]History, error)39 GetAllRecentHistory(ctx context.Context) ([]History, error)
40+ GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error)
39 GetAllSessionStates(ctx context.Context) ([]SessionState, error)41 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
40 GetAllTags(ctx context.Context) ([]FavoriteTag, error)42 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
41 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)43 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
@@ -54,7 +56,10 @@ type Querier interface {
54 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)56 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
55 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)57 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
56 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)58 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
59+ GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
57 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)60 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
61+ GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)
62+ GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)
58 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)63 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
59 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)64 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
60 GetSessionByID(ctx context.Context, id string) (Session, error)65 GetSessionByID(ctx context.Context, id string) (Session, error)
@@ -65,6 +70,8 @@ type Querier interface {
65 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)70 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
66 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)71 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
67 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)72 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
73+ HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error)
74+ HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error)
68 IncrementVisitCount(ctx context.Context, url string) error75 IncrementVisitCount(ctx context.Context, url string) error
69 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error76 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
70 InsertSession(ctx context.Context, arg InsertSessionParams) error77 InsertSession(ctx context.Context, arg InsertSessionParams) error
A internal/infrastructure/systemviewsbridge/browser_transport.go +133 -0
@@ -0,0 +1,133 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+)
9+
10+type browserRequest struct {
11+ messageType string
12+ requestID string
13+ body []byte
14+ directAPI bool
15+}
16+
17+func decodeBrowserRequest(body []byte) (browserRequest, error) {
18+ var envelope struct {
19+ Type string `json:"type"`
20+ Payload json.RawMessage `json:"payload"`
21+ }
22+ if err := json.Unmarshal(body, &envelope); err != nil {
23+ return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err)
24+ }
25+ if envelope.Type == "" {
26+ return browserRequest{}, fmt.Errorf("browser request missing type")
27+ }
28+
29+ var payload struct {
30+ RequestID string `json:"requestId"`
31+ }
32+ if len(envelope.Payload) != 0 {
33+ if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
34+ return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err)
35+ }
36+ }
37+
38+ return browserRequest{
39+ messageType: envelope.Type,
40+ requestID: payload.RequestID,
41+ body: body,
42+ directAPI: strings.HasPrefix(envelope.Type, "/api/"),
43+ }, nil
44+}
45+
46+func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) {
47+ return json.Marshal(bridgeResponse{
48+ RequestID: requestID,
49+ Success: true,
50+ Data: json.RawMessage(payload),
51+ })
52+}
53+
54+// normalizeBridgeShimResponse accepts the bridge shapes emitted by native and
55+// fallback shims: a direct bridgeResponse envelope, a wrapper with data/error,
56+// or a wrapper whose data field itself contains a bridgeResponse envelope.
57+func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {
58+ trimmed := bytes.TrimSpace(body)
59+ if len(trimmed) == 0 {
60+ return nil, fmt.Errorf("empty bridge shim response")
61+ }
62+
63+ var topLevel map[string]json.RawMessage
64+ if err := json.Unmarshal(trimmed, &topLevel); err != nil {
65+ return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err)
66+ }
67+
68+ _, hasSuccess := topLevel["success"]
69+ _, hasRequestID := topLevel["requestId"]
70+ _, hasCallback := topLevel["_callback"]
71+ _, hasData := topLevel["data"]
72+
73+ // Native callbacks can already return the final bridgeResponse shape.
74+ if (hasSuccess || hasRequestID) && !hasCallback {
75+ var resp bridgeResponse
76+ if err := json.Unmarshal(trimmed, &resp); err != nil {
77+ return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)
78+ }
79+ if resp.RequestID == "" {
80+ // Some direct shims omit requestId; preserve correlation for callers.
81+ resp.RequestID = requestID
82+ }
83+ return json.Marshal(resp)
84+ }
85+
86+ var wrapped struct {
87+ Data json.RawMessage `json:"data,omitempty"`
88+ Error string `json:"error,omitempty"`
89+ }
90+ if err := json.Unmarshal(trimmed, &wrapped); err != nil {
91+ return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)
92+ }
93+ if wrapped.Error != "" {
94+ // Fetch-style wrappers report transport errors outside the data payload.
95+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})
96+ }
97+ if !hasData {
98+ // Wrapped shim responses must carry either data or a top-level error.
99+ return nil, fmt.Errorf("wrapped bridge response missing data")
100+ }
101+ if len(wrapped.Data) == 0 {
102+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
103+ }
104+
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)
116+ if hasNestedEnvelope {
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)
124+ }
125+ }
126+ }
127+
128+ return json.Marshal(bridgeResponse{
129+ RequestID: requestID,
130+ Success: true,
131+ Data: wrapped.Data,
132+ })
133+}
A internal/infrastructure/systemviewsbridge/browser_transport_js.go +484 -0
@@ -0,0 +1,484 @@
1+//go:build js && wasm
2+
3+package systemviewsbridge
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "strings"
12+ "sync"
13+ "syscall/js"
14+)
15+
16+type callbackPlan struct {
17+ success string
18+ failure string
19+}
20+
21+func NewBrowserClient() *Client {
22+ return NewClient(&webkitTransport{}, &browserTransport{})
23+}
24+
25+func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
26+ switch msgType {
27+ case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics",
28+ "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",
29+ "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
30+ "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
31+ return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
32+ case "save_config":
33+ return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true
34+ case "get_keybindings":
35+ return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true
36+ case "set_keybinding":
37+ return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true
38+ case "reset_keybinding":
39+ return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true
40+ case "reset_all_keybindings":
41+ return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true
42+ default:
43+ return callbackPlan{}, false
44+ }
45+}
46+
47+type browserTransport struct{}
48+
49+func (*browserTransport) Available() bool {
50+ window := js.Global().Get("window")
51+ if !window.Truthy() {
52+ return false
53+ }
54+ bridge := window.Get("dumber")
55+ return bridge.Truthy() && bridge.Get("postMessage").Truthy()
56+}
57+
58+func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
59+ req, err := decodeBrowserRequest(body)
60+ if err != nil {
61+ return nil, err
62+ }
63+ if req.directAPI {
64+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
65+ }
66+
67+ window := js.Global().Get("window")
68+ bridge := window.Get("dumber")
69+ if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() {
70+ return nil, errors.New("browser bridge shim not available")
71+ }
72+
73+ promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
74+ value, err := awaitPromise(ctx, promise)
75+ if err != nil {
76+ return nil, err
77+ }
78+ raw, err := stringifyValue(value)
79+ if err != nil {
80+ return nil, err
81+ }
82+ return normalizeBridgeShimResponse(req.requestID, raw)
83+}
84+
85+type webkitTransport struct{}
86+
87+type webkitCallbackResult struct {
88+ body []byte
89+ err error
90+}
91+
92+type webkitCallbackWaiter struct {
93+ requestID string
94+ successName string
95+ failureName string
96+ resultCh chan webkitCallbackResult
97+}
98+
99+// webkitCallbacks uses one page-lifetime dispatcher per native callback name.
100+// Multiple requests may share callback names concurrently. When native payloads
101+// include requestId, dispatch to that waiter; older callbacks without requestId
102+// are resolved FIFO so they cannot target 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+
112+func (*webkitTransport) Available() bool {
113+ window := js.Global().Get("window")
114+ if !window.Truthy() {
115+ return false
116+ }
117+ webkit := window.Get("webkit")
118+ if !webkit.Truthy() {
119+ return false
120+ }
121+ handlers := webkit.Get("messageHandlers")
122+ if !handlers.Truthy() {
123+ return false
124+ }
125+ return handlers.Get("dumber").Truthy()
126+}
127+
128+func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
129+ req, err := decodeBrowserRequest(body)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if req.directAPI {
134+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
135+ }
136+
137+ plan, ok := callbackPlanForMessage(req.messageType)
138+ if !ok {
139+ return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType)
140+ }
141+
142+ window := js.Global().Get("window")
143+ handler := window.Get("webkit").Get("messageHandlers").Get("dumber")
144+ if !handler.Truthy() {
145+ return nil, errors.New("webkit message handler not available")
146+ }
147+
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
155+ }
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()
165+ }
166+}
167+
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)
176+ return nil
177+ })
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+ webkitCallbacks.waiters[plan.success] = append(webkitCallbacks.waiters[plan.success], waiter)
196+ webkitCallbacks.waiters[plan.failure] = append(webkitCallbacks.waiters[plan.failure], waiter)
197+ return func() {
198+ webkitCallbacks.Lock()
199+ defer webkitCallbacks.Unlock()
200+ removeWebKitWaiterLocked(waiter)
201+ }, nil
202+}
203+
204+func findWebKitWaiterLocked(name, requestID string) *webkitCallbackWaiter {
205+ waiters := webkitCallbacks.waiters[name]
206+ if len(waiters) == 0 {
207+ return nil
208+ }
209+ if requestID == "" {
210+ return waiters[0]
211+ }
212+ for _, waiter := range waiters {
213+ if waiter != nil && waiter.requestID == requestID {
214+ return waiter
215+ }
216+ }
217+ return nil
218+}
219+
220+func removeWebKitWaiterLocked(waiter *webkitCallbackWaiter) {
221+ if waiter == nil {
222+ return
223+ }
224+ remove := func(name string) {
225+ waiters := webkitCallbacks.waiters[name]
226+ for i, candidate := range waiters {
227+ if candidate != waiter {
228+ continue
229+ }
230+ waiters = append(waiters[:i], waiters[i+1:]...)
231+ if len(waiters) == 0 {
232+ delete(webkitCallbacks.waiters, name)
233+ } else {
234+ webkitCallbacks.waiters[name] = waiters
235+ }
236+ return
237+ }
238+ }
239+ remove(waiter.successName)
240+ remove(waiter.failureName)
241+}
242+
243+func handleWebKitCallback(name string, failure bool, args []js.Value) {
244+ payload, payloadErr := stringifyArgs(args)
245+ payloadRequestID := requestIDFromBridgePayload(payload)
246+
247+ webkitCallbacks.Lock()
248+ waiter := findWebKitWaiterLocked(name, payloadRequestID)
249+ if waiter != nil {
250+ removeWebKitWaiterLocked(waiter)
251+ }
252+ webkitCallbacks.Unlock()
253+
254+ if waiter == nil {
255+ warnDroppedBridgeResult("webkit callback without waiter", nil)
256+ return
257+ }
258+
259+ res := webkitCallbackResult{err: payloadErr}
260+ if payloadErr == nil {
261+ if failure {
262+ res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload)
263+ } else {
264+ res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload)
265+ }
266+ }
267+ select {
268+ case waiter.resultCh <- res:
269+ default:
270+ warnDroppedBridgeResult("webkit callback", res.err)
271+ }
272+}
273+
274+func requestIDFromBridgePayload(payload []byte) string {
275+ if len(bytes.TrimSpace(payload)) == 0 {
276+ return ""
277+ }
278+ var envelope struct {
279+ RequestID string `json:"requestId"`
280+ }
281+ if err := json.Unmarshal(payload, &envelope); err != nil {
282+ return ""
283+ }
284+ return envelope.RequestID
285+}
286+
287+func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {
288+ fetch := js.Global().Get("fetch")
289+ if !fetch.Truthy() {
290+ return nil, errors.New("fetch API not available")
291+ }
292+ response, err := awaitPromise(ctx, fetch.Invoke(endpoint))
293+ if err != nil {
294+ return nil, err
295+ }
296+ if !response.Get("ok").Bool() {
297+ status := response.Get("status").Int()
298+ statusText := response.Get("statusText").String()
299+ if statusText == "" {
300+ statusText = "request failed"
301+ }
302+ return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText)
303+ }
304+ data, err := awaitPromise(ctx, response.Call("json"))
305+ if err != nil {
306+ return nil, err
307+ }
308+ raw, err := stringifyValue(data)
309+ if err != nil {
310+ return nil, err
311+ }
312+ return wrapDirectAPIResponse(requestID, raw)
313+}
314+
315+func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) {
316+ trimmed := bytes.TrimSpace(payload)
317+ if len(trimmed) == 0 {
318+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
319+ }
320+
321+ var resp bridgeResponse
322+ if err := json.Unmarshal(trimmed, &resp); err == nil {
323+ hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != ""
324+ if hasEnvelope {
325+ if resp.RequestID == "" {
326+ resp.RequestID = requestID
327+ }
328+ return json.Marshal(resp)
329+ }
330+ }
331+
332+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)})
333+}
334+
335+func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) {
336+ trimmed := bytes.TrimSpace(payload)
337+ var message string
338+ if len(trimmed) == 0 {
339+ message = "bridge request failed"
340+ } else if err := json.Unmarshal(trimmed, &message); err != nil {
341+ message = strings.Trim(string(trimmed), `"`)
342+ }
343+ if message == "" {
344+ message = "bridge request failed"
345+ }
346+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: message})
347+}
348+
349+func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
350+ if ctx == nil {
351+ ctx = context.Background()
352+ }
353+ type result struct {
354+ value js.Value
355+ err error
356+ }
357+ resultCh := make(chan result, 1)
358+
359+ promiseCtor := js.Global().Get("Promise")
360+ if !promiseCtor.Truthy() {
361+ return js.Undefined(), errors.New("Promise global not available")
362+ }
363+ var (
364+ cancelMu sync.Mutex
365+ reject = js.Undefined()
366+ )
367+ executor := js.FuncOf(func(_ js.Value, args []js.Value) any {
368+ if len(args) >= 2 {
369+ cancelMu.Lock()
370+ reject = args[1]
371+ cancelMu.Unlock()
372+ }
373+ return nil
374+ })
375+ cancelPromise := promiseCtor.New(executor)
376+ executor.Release()
377+ raceInput := js.Global().Get("Array").New(promise, cancelPromise)
378+ racePromise := promiseCtor.Call("race", raceInput)
379+
380+ done := make(chan struct{})
381+ go func() {
382+ select {
383+ case <-ctx.Done():
384+ cancelMu.Lock()
385+ rejectFn := reject
386+ cancelMu.Unlock()
387+ if rejectFn.Truthy() {
388+ rejectFn.Invoke(ctx.Err().Error())
389+ }
390+ case <-done:
391+ }
392+ }()
393+ defer close(done)
394+
395+ var (
396+ callbackMu sync.Mutex
397+ released bool
398+ thenCb js.Func
399+ catchCb js.Func
400+ )
401+ releaseCallbacks := func() {
402+ callbackMu.Lock()
403+ defer callbackMu.Unlock()
404+ if released {
405+ return
406+ }
407+ released = true
408+ thenCb.Release()
409+ catchCb.Release()
410+ }
411+ publish := func(res result) {
412+ select {
413+ case resultCh <- res:
414+ default:
415+ warnDroppedBridgeResult("promise", res.err)
416+ }
417+ }
418+
419+ thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
420+ defer releaseCallbacks()
421+ if len(args) == 0 {
422+ publish(result{value: js.Undefined()})
423+ return nil
424+ }
425+ publish(result{value: args[0]})
426+ return nil
427+ })
428+ catchCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
429+ defer releaseCallbacks()
430+ if len(args) == 0 {
431+ publish(result{err: errors.New("promise rejected")})
432+ return nil
433+ }
434+ message := args[0].String()
435+ if ctxErr := ctx.Err(); ctxErr != nil && message == ctxErr.Error() {
436+ publish(result{err: ctxErr})
437+ return nil
438+ }
439+ publish(result{err: errors.New(message)})
440+ return nil
441+ })
442+ attachPromiseCallbacks(racePromise, thenCb, catchCb)
443+
444+ res := <-resultCh
445+ return res.value, res.err
446+}
447+
448+func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) {
449+ promise.Call("then", thenCb, catchCb)
450+}
451+
452+func warnDroppedBridgeResult(scope string, err error) {
453+ console := js.Global().Get("console")
454+ if !console.Truthy() || !console.Get("warn").Truthy() {
455+ return
456+ }
457+ if err != nil {
458+ console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error())
459+ return
460+ }
461+ console.Call("warn", "dumber systemviews bridge dropped "+scope+" result")
462+}
463+
464+func stringifyArgs(args []js.Value) ([]byte, error) {
465+ if len(args) == 0 {
466+ return nil, nil
467+ }
468+ return stringifyValue(args[0])
469+}
470+
471+func stringifyValue(value js.Value) ([]byte, error) {
472+ jsonValue := js.Global().Get("JSON")
473+ if !jsonValue.Truthy() {
474+ return nil, errors.New("JSON global not available")
475+ }
476+ stringified := jsonValue.Call("stringify", value)
477+ if stringified.Type() == js.TypeString {
478+ return []byte(stringified.String()), nil
479+ }
480+ if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) {
481+ return []byte("null"), nil
482+ }
483+ return nil, errors.New("failed to stringify JS value")
484+}
A internal/infrastructure/systemviewsbridge/browser_transport_test.go +208 -0
@@ -0,0 +1,208 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+)
8+
9+func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) {
10+ t.Parallel()
11+
12+ body, err := buildMessageEnvelope("/api/config", struct {
13+ RequestID string `json:"requestId"`
14+ }{RequestID: "req-1"})
15+ if err != nil {
16+ t.Fatalf("buildMessageEnvelope() error = %v", err)
17+ }
18+
19+ req, err := decodeBrowserRequest(body)
20+ if err != nil {
21+ t.Fatalf("decodeBrowserRequest() error = %v", err)
22+ }
23+ if req.messageType != "/api/config" {
24+ t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config")
25+ }
26+ if req.requestID != "req-1" {
27+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-1")
28+ }
29+ if !req.directAPI {
30+ t.Fatal("directAPI = false, want true")
31+ }
32+}
33+
34+func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) {
35+ t.Parallel()
36+
37+ body, err := buildMessageEnvelope("history_timeline", struct {
38+ RequestID string `json:"requestId"`
39+ Limit int `json:"limit"`
40+ }{RequestID: "req-2", Limit: 25})
41+ if err != nil {
42+ t.Fatalf("buildMessageEnvelope() error = %v", err)
43+ }
44+
45+ req, err := decodeBrowserRequest(body)
46+ if err != nil {
47+ t.Fatalf("decodeBrowserRequest() error = %v", err)
48+ }
49+ if req.messageType != "history_timeline" {
50+ t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline")
51+ }
52+ if req.requestID != "req-2" {
53+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-2")
54+ }
55+ if req.directAPI {
56+ t.Fatal("directAPI = true, want false")
57+ }
58+ if !bytes.Equal(req.body, body) {
59+ t.Fatalf("body = %s, want %s", req.body, body)
60+ }
61+}
62+
63+func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) {
64+ t.Parallel()
65+
66+ got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`))
67+ if err != nil {
68+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
69+ }
70+
71+ var resp bridgeResponse
72+ if err := json.Unmarshal(got, &resp); err != nil {
73+ t.Fatalf("json.Unmarshal() error = %v", err)
74+ }
75+ if !resp.Success {
76+ t.Fatal("success = false, want true")
77+ }
78+ if resp.RequestID != "req-3" {
79+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3")
80+ }
81+ if string(resp.Data) != `[{"title":"Example"}]` {
82+ t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`)
83+ }
84+}
85+
86+func TestNormalizeBridgeShimResponse_DoesNotTreatRequestIDDataAsEnvelope(t *testing.T) {
87+ t.Parallel()
88+
89+ got, err := normalizeBridgeShimResponse("req-raw", []byte(`{"data":{"requestId":"external-id","title":"Example"},"_callback":"__dumber_homepage_response"}`))
90+ if err != nil {
91+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
92+ }
93+
94+ var resp bridgeResponse
95+ if err := json.Unmarshal(got, &resp); err != nil {
96+ t.Fatalf("json.Unmarshal() error = %v", err)
97+ }
98+ if string(resp.Data) != `{"requestId":"external-id","title":"Example"}` {
99+ t.Fatalf("data = %s, want raw payload", resp.Data)
100+ }
101+}
102+
103+func TestNormalizeBridgeShimResponse_DoesNotTreatNonBooleanSuccessDataAsEnvelope(t *testing.T) {
104+ t.Parallel()
105+
106+ got, err := normalizeBridgeShimResponse("req-raw-success", []byte(`{"data":{"success":"yes","title":"Example"},"_callback":"__dumber_homepage_response"}`))
107+ if err != nil {
108+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
109+ }
110+
111+ var resp bridgeResponse
112+ if err := json.Unmarshal(got, &resp); err != nil {
113+ t.Fatalf("json.Unmarshal() error = %v", err)
114+ }
115+ if string(resp.Data) != `{"success":"yes","title":"Example"}` {
116+ t.Fatalf("data = %s, want raw payload", resp.Data)
117+ }
118+}
119+
120+func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
121+ t.Parallel()
122+
123+ got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`))
124+ if err != nil {
125+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
126+ }
127+
128+ var resp bridgeResponse
129+ if err := json.Unmarshal(got, &resp); err != nil {
130+ t.Fatalf("json.Unmarshal() error = %v", err)
131+ }
132+ if !resp.Success {
133+ t.Fatal("success = false, want true")
134+ }
135+ if resp.RequestID != "req-4" {
136+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4")
137+ }
138+ if string(resp.Data) != `{"status":"success"}` {
139+ t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`)
140+ }
141+}
142+
143+func TestNormalizeBridgeShimResponse_WrapsTopLevelErrorWithoutData(t *testing.T) {
144+ t.Parallel()
145+
146+ got, err := normalizeBridgeShimResponse("req-error", []byte(`{"error":"bridge failed"}`))
147+ if err != nil {
148+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
149+ }
150+
151+ var resp bridgeResponse
152+ if err := json.Unmarshal(got, &resp); err != nil {
153+ t.Fatalf("json.Unmarshal() error = %v", err)
154+ }
155+ if resp.RequestID != "req-error" {
156+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-error")
157+ }
158+ if resp.Error != "bridge failed" {
159+ t.Fatalf("error = %q, want %q", resp.Error, "bridge failed")
160+ }
161+}
162+
163+func TestNormalizeBridgeShimResponse_RejectsMalformedJSON(t *testing.T) {
164+ t.Parallel()
165+
166+ if _, err := normalizeBridgeShimResponse("req-bad", []byte(`{"data":`)); err == nil {
167+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
168+ }
169+}
170+
171+func TestNormalizeBridgeShimResponse_RejectsMissingData(t *testing.T) {
172+ t.Parallel()
173+
174+ if _, err := normalizeBridgeShimResponse("req-missing", []byte(`{"_callback":"__dumber_config_saved"}`)); err == nil {
175+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
176+ }
177+}
178+
179+func TestNormalizeBridgeShimResponse_RejectsEmptyBody(t *testing.T) {
180+ t.Parallel()
181+
182+ if _, err := normalizeBridgeShimResponse("req-empty", nil); err == nil {
183+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
184+ }
185+}
186+
187+func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {
188+ t.Parallel()
189+
190+ got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`))
191+ if err != nil {
192+ t.Fatalf("wrapDirectAPIResponse() error = %v", err)
193+ }
194+
195+ var resp bridgeResponse
196+ if err := json.Unmarshal(got, &resp); err != nil {
197+ t.Fatalf("json.Unmarshal() error = %v", err)
198+ }
199+ if !resp.Success {
200+ t.Fatal("success = false, want true")
201+ }
202+ if resp.RequestID != "req-5" {
203+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5")
204+ }
205+ if string(resp.Data) != `{"engine_type":"webkit"}` {
206+ t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`)
207+ }
208+}
A internal/infrastructure/systemviewsbridge/client.go +573 -0
@@ -0,0 +1,573 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/application/dto"
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+)
16+
17+// Transport sends a WebUI message envelope and returns the raw response body.
18+type Transport interface {
19+ Available() bool
20+ Send(ctx context.Context, body []byte) ([]byte, error)
21+}
22+
23+// Client sends WebUI message envelopes through the first available transport.
24+type Client struct {
25+ native Transport
26+ fetch Transport
27+ requestTimeout time.Duration
28+}
29+
30+var _ port.SystemviewConfigService = (*Client)(nil)
31+var _ port.SystemviewHistoryService = (*Client)(nil)
32+var _ port.SystemviewFavoritesService = (*Client)(nil)
33+
34+var requestSeq atomic.Uint64
35+
36+const defaultBridgeRequestTimeout = 15 * time.Second
37+
38+// ClientOption customizes a bridge client.
39+type ClientOption func(*Client)
40+
41+// WithRequestTimeout sets the timeout used when callers provide no deadline.
42+func WithRequestTimeout(timeout time.Duration) ClientOption {
43+ return func(c *Client) {
44+ c.requestTimeout = timeout
45+ }
46+}
47+
48+// NewClient creates a bridge client with native WebKit and fetch transports.
49+func NewClient(native, fetch Transport, opts ...ClientOption) *Client {
50+ client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout}
51+ for _, opt := range opts {
52+ if opt != nil {
53+ opt(client)
54+ }
55+ }
56+ return client
57+}
58+
59+// Send builds a WebUI message envelope and sends it through the active transport.
60+func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) {
61+ if c == nil {
62+ return nil, errors.New("bridge client is nil")
63+ }
64+
65+ envelope, err := buildMessageEnvelope(msgType, payload)
66+ if err != nil {
67+ return nil, err
68+ }
69+
70+ transport := c.transport()
71+ if transport == nil {
72+ return nil, errors.New("no bridge transport available")
73+ }
74+
75+ return transport.Send(ctx, envelope)
76+}
77+
78+func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
79+ if limit < 0 {
80+ return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit)
81+ }
82+ if offset < 0 {
83+ return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset)
84+ }
85+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
86+ RequestID string `json:"requestId"`
87+ Limit int `json:"limit"`
88+ Offset int `json:"offset"`
89+ }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
90+}
91+
92+func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
93+ domain = strings.TrimSpace(domain)
94+ if domain == "" {
95+ return nil, fmt.Errorf("timeline-by-domain domain must be non-empty")
96+ }
97+ if limit < 0 {
98+ return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit)
99+ }
100+ if offset < 0 {
101+ return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset)
102+ }
103+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
104+ RequestID string `json:"requestId"`
105+ Domain string `json:"domain"`
106+ Limit int `json:"limit"`
107+ Offset int `json:"offset"`
108+ }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109+}
110+
111+func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
112+ beforeCursor := ""
113+ if !before.IsZero() {
114+ beforeCursor = before.Format(time.RFC3339Nano)
115+ }
116+ return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117+ RequestID string `json:"requestId"`
118+ Before string `json:"before,omitempty"`
119+ Domain string `json:"domain,omitempty"`
120+ }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)})
121+}
122+
123+func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
124+ if limit < 0 {
125+ return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
126+ }
127+ return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
128+ RequestID string `json:"requestId"`
129+ Query string `json:"query"`
130+ Limit int `json:"limit"`
131+ }{RequestID: nextRequestID(), Query: query, Limit: limit})
132+}
133+
134+func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
135+ if id <= 0 {
136+ return fmt.Errorf("delete entry id must be positive, got %d", id)
137+ }
138+ _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
139+ RequestID string `json:"requestId"`
140+ ID int64 `json:"id"`
141+ }{RequestID: nextRequestID(), ID: id})
142+ return err
143+}
144+
145+func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
146+ rangeID = strings.TrimSpace(rangeID)
147+ if rangeID == "" {
148+ return errors.New("history range ID cannot be empty")
149+ }
150+ _, err := request[struct{}](c, ctx, "history_delete_range", struct {
151+ RequestID string `json:"requestId"`
152+ Range string `json:"range"`
153+ }{RequestID: nextRequestID(), Range: rangeID})
154+ return err
155+}
156+
157+// Current intentionally targets the direct HTTP config endpoint rather than a
158+// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
159+func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
160+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
161+ RequestID string `json:"requestId"`
162+ }{RequestID: nextRequestID()})
163+}
164+
165+// Default intentionally targets the direct HTTP config endpoint rather than a
166+// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
167+func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
168+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
169+ RequestID string `json:"requestId"`
170+ }{RequestID: nextRequestID()})
171+}
172+
173+func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
174+ _, err := request[struct{}](c, ctx, "save_config", cfg)
175+ return err
176+}
177+
178+func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
179+ return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
180+ RequestID string `json:"requestId"`
181+ }{RequestID: nextRequestID()})
182+}
183+
184+func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
185+ return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
186+}
187+
188+func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
189+ _, err := request[struct{}](c, ctx, "reset_keybinding", req)
190+ return err
191+}
192+
193+func (c *Client) ResetAllKeybindings(ctx context.Context) error {
194+ _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
195+ RequestID string `json:"requestId"`
196+ }{RequestID: nextRequestID()})
197+ return err
198+}
199+
200+func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) {
201+ return request[*entity.HistoryStats](c, ctx, "history_stats", struct {
202+ RequestID string `json:"requestId"`
203+ }{RequestID: nextRequestID()})
204+}
205+
206+func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
207+ return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
208+ RequestID string `json:"requestId"`
209+ }{RequestID: nextRequestID()})
210+}
211+
212+func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
213+ if limit < 0 {
214+ return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
215+ }
216+ return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
217+ RequestID string `json:"requestId"`
218+ Limit int `json:"limit"`
219+ }{RequestID: nextRequestID(), Limit: limit})
220+}
221+
222+func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
223+ domain = strings.TrimSpace(domain)
224+ if domain == "" {
225+ return errors.New("history domain cannot be empty")
226+ }
227+ _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
228+ RequestID string `json:"requestId"`
229+ Domain string `json:"domain"`
230+ }{RequestID: nextRequestID(), Domain: domain})
231+ return err
232+}
233+
234+func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
235+ return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
236+ RequestID string `json:"requestId"`
237+ }{RequestID: nextRequestID()})
238+}
239+
240+func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
241+ return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
242+}
243+
244+func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
245+ if input.ID <= 0 {
246+ return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
247+ }
248+ return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
249+}
250+
251+func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
252+ if id <= 0 {
253+ return fmt.Errorf("delete favorite id must be positive, got %d", id)
254+ }
255+ _, err := request[struct{}](c, ctx, "favorite_delete", struct {
256+ RequestID string `json:"requestId"`
257+ ID int64 `json:"id"`
258+ }{RequestID: nextRequestID(), ID: id})
259+ return err
260+}
261+
262+func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
263+ return struct {
264+ RequestID string `json:"requestId"`
265+ URL string `json:"url"`
266+ Title string `json:"title"`
267+ FaviconURL string `json:"favicon_url"`
268+ FolderID *int64 `json:"folder_id"`
269+ Tags []int64 `json:"tags"`
270+ }{
271+ RequestID: nextRequestID(),
272+ URL: input.URL,
273+ Title: input.Title,
274+ FaviconURL: input.FaviconURL,
275+ FolderID: folderIDPayload(input.FolderID),
276+ Tags: tagIDPayloads(input.Tags),
277+ }
278+}
279+
280+func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
281+ return struct {
282+ RequestID string `json:"requestId"`
283+ ID int64 `json:"id"`
284+ Title string `json:"title"`
285+ FaviconURL string `json:"favicon_url"`
286+ FolderID *int64 `json:"folder_id"`
287+ ShortcutKey *int `json:"shortcut_key"`
288+ }{
289+ RequestID: nextRequestID(),
290+ ID: int64(input.ID),
291+ Title: input.Title,
292+ FaviconURL: input.FaviconURL,
293+ FolderID: folderIDPayload(input.FolderID),
294+ ShortcutKey: input.ShortcutKey,
295+ }
296+}
297+
298+func folderIDPayload(id *entity.FolderID) *int64 {
299+ if id == nil {
300+ return nil
301+ }
302+ value := int64(*id)
303+ return &value
304+}
305+
306+func tagIDPayloads(ids []entity.TagID) []int64 {
307+ out := make([]int64, 0, len(ids))
308+ for _, id := range ids {
309+ out = append(out, int64(id))
310+ }
311+ return out
312+}
313+
314+func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
315+ return request[[]*entity.Folder](c, ctx, "folder_list", struct {
316+ RequestID string `json:"requestId"`
317+ }{RequestID: nextRequestID()})
318+}
319+
320+func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
321+ return request[[]*entity.Tag](c, ctx, "tag_list", struct {
322+ RequestID string `json:"requestId"`
323+ }{RequestID: nextRequestID()})
324+}
325+
326+func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
327+ if favoriteID <= 0 {
328+ return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
329+ }
330+ _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
331+ RequestID string `json:"requestId"`
332+ FavoriteID int64 `json:"favorite_id"`
333+ ShortcutKey *int `json:"shortcut_key"`
334+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
335+ return err
336+}
337+
338+func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
339+ if favoriteID <= 0 {
340+ return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
341+ }
342+ _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
343+ RequestID string `json:"requestId"`
344+ FavoriteID int64 `json:"favorite_id"`
345+ FolderID *int64 `json:"folder_id"`
346+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
347+ return err
348+}
349+
350+func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
351+ name = strings.TrimSpace(name)
352+ if name == "" {
353+ return nil, fmt.Errorf("folder name is required")
354+ }
355+ var iconPtr *string
356+ if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
357+ iconPtr = &trimmedIcon
358+ }
359+ return request[*entity.Folder](c, ctx, "folder_create", struct {
360+ RequestID string `json:"requestId"`
361+ Name string `json:"name"`
362+ Icon *string `json:"icon"`
363+ ParentID *int64 `json:"parent_id,omitempty"`
364+ }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
365+}
366+
367+func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
368+ if id <= 0 {
369+ return fmt.Errorf("update folder id must be positive, got %d", id)
370+ }
371+ name = strings.TrimSpace(name)
372+ if name == "" {
373+ return fmt.Errorf("folder name is required")
374+ }
375+ var iconPtr *string
376+ if trimmed := strings.TrimSpace(icon); trimmed != "" {
377+ iconPtr = &trimmed
378+ }
379+ _, err := request[struct{}](c, ctx, "folder_update", struct {
380+ RequestID string `json:"requestId"`
381+ ID int64 `json:"id"`
382+ Name string `json:"name"`
383+ Icon *string `json:"icon"`
384+ }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
385+ return err
386+}
387+
388+func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
389+ if id <= 0 {
390+ return fmt.Errorf("delete folder id must be positive, got %d", id)
391+ }
392+ _, err := request[struct{}](c, ctx, "folder_delete", struct {
393+ RequestID string `json:"requestId"`
394+ ID int64 `json:"id"`
395+ }{RequestID: nextRequestID(), ID: id})
396+ return err
397+}
398+
399+func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
400+ name = strings.TrimSpace(name)
401+ if name == "" {
402+ return nil, fmt.Errorf("tag name is required")
403+ }
404+ var colorPtr *string
405+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
406+ colorPtr = &trimmedColor
407+ }
408+ return request[*entity.Tag](c, ctx, "tag_create", struct {
409+ RequestID string `json:"requestId"`
410+ Name string `json:"name"`
411+ Color *string `json:"color"`
412+ }{RequestID: nextRequestID(), Name: name, Color: colorPtr})
413+}
414+
415+func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
416+ if id <= 0 {
417+ return fmt.Errorf("update tag id must be positive, got %d", id)
418+ }
419+ trimmedName := strings.TrimSpace(name)
420+ if trimmedName == "" {
421+ return fmt.Errorf("tag name is required")
422+ }
423+ namePtr := &trimmedName
424+ var colorPtr *string
425+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
426+ colorPtr = &trimmedColor
427+ }
428+ _, err := request[struct{}](c, ctx, "tag_update", struct {
429+ RequestID string `json:"requestId"`
430+ ID int64 `json:"id"`
431+ Name *string `json:"name"`
432+ Color *string `json:"color"`
433+ }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
434+ return err
435+}
436+
437+func (c *Client) DeleteTag(ctx context.Context, id int64) error {
438+ if id <= 0 {
439+ return fmt.Errorf("delete tag id must be positive, got %d", id)
440+ }
441+ _, err := request[struct{}](c, ctx, "tag_delete", struct {
442+ RequestID string `json:"requestId"`
443+ ID int64 `json:"id"`
444+ }{RequestID: nextRequestID(), ID: id})
445+ return err
446+}
447+
448+func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
449+ if favoriteID <= 0 {
450+ return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
451+ }
452+ if tagID <= 0 {
453+ return fmt.Errorf("assign tag id must be positive, got %d", tagID)
454+ }
455+ _, err := request[struct{}](c, ctx, "tag_assign", struct {
456+ RequestID string `json:"requestId"`
457+ FavoriteID int64 `json:"favorite_id"`
458+ TagID int64 `json:"tag_id"`
459+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
460+ return err
461+}
462+
463+func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
464+ if favoriteID <= 0 {
465+ return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
466+ }
467+ if tagID <= 0 {
468+ return fmt.Errorf("remove tag id must be positive, got %d", tagID)
469+ }
470+ _, err := request[struct{}](c, ctx, "tag_remove", struct {
471+ RequestID string `json:"requestId"`
472+ FavoriteID int64 `json:"favorite_id"`
473+ TagID int64 `json:"tag_id"`
474+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
475+ return err
476+}
477+
478+func (c *Client) transport() Transport {
479+ if c == nil {
480+ return nil
481+ }
482+ if c.native != nil && c.native.Available() {
483+ return c.native
484+ }
485+ if c.fetch != nil && c.fetch.Available() {
486+ return c.fetch
487+ }
488+ return nil
489+}
490+
491+func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
492+ var zero T
493+
494+ ctx, cancel := c.withBridgeRequestTimeout(ctx)
495+ defer cancel()
496+
497+ raw, err := c.Send(ctx, msgType, payload)
498+ if err != nil {
499+ return zero, err
500+ }
501+
502+ return decodeBridgeResponse[T](raw)
503+}
504+
505+// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
506+func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
507+ timeout := defaultBridgeRequestTimeout
508+ if c != nil {
509+ timeout = c.requestTimeout
510+ }
511+ if _, ok := ctx.Deadline(); ok || timeout <= 0 {
512+ return context.WithCancel(ctx)
513+ }
514+ return context.WithTimeout(ctx, timeout)
515+}
516+
517+type bridgeResponse struct {
518+ RequestID string `json:"requestId"`
519+ Success bool `json:"success"`
520+ Data json.RawMessage `json:"data,omitempty"`
521+ Error string `json:"error,omitempty"`
522+}
523+
524+func decodeBridgeResponse[T any](body []byte) (T, error) {
525+ var result T
526+
527+ var resp bridgeResponse
528+ if err := json.Unmarshal(body, &resp); err != nil {
529+ return result, fmt.Errorf("unmarshal bridge response: %w", err)
530+ }
531+ if !resp.Success {
532+ if resp.Error == "" {
533+ return result, errors.New("bridge request failed")
534+ }
535+ return result, errors.New(resp.Error)
536+ }
537+ if len(resp.Data) == 0 {
538+ return result, nil
539+ }
540+
541+ if err := json.Unmarshal(resp.Data, &result); err != nil {
542+ return result, fmt.Errorf("unmarshal bridge data: %w", err)
543+ }
544+
545+ return result, nil
546+}
547+
548+func nextRequestID() string {
549+ return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
550+}
551+
552+func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
553+ if msgType == "" {
554+ return nil, errors.New("message type cannot be empty")
555+ }
556+
557+ body, err := json.Marshal(payload)
558+ if err != nil {
559+ return nil, fmt.Errorf("marshal payload: %w", err)
560+ }
561+
562+ envelope := port.WebUIMessage{
563+ Type: msgType,
564+ Payload: body,
565+ }
566+
567+ data, err := json.Marshal(envelope)
568+ if err != nil {
569+ return nil, fmt.Errorf("marshal envelope: %w", err)
570+ }
571+
572+ return data, nil
573+}
A internal/infrastructure/systemviewsbridge/client_test.go +702 -0
@@ -0,0 +1,702 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+func TestBuildMessageEnvelope(t *testing.T) {
15+ t.Parallel()
16+
17+ payload := struct {
18+ RequestID string `json:"requestId"`
19+ Limit int `json:"limit"`
20+ }{RequestID: "req-1", Limit: 25}
21+
22+ got, err := buildMessageEnvelope("history_timeline", payload)
23+ if err != nil {
24+ t.Fatalf("buildMessageEnvelope() error = %v", err)
25+ }
26+
27+ var msg port.WebUIMessage
28+ if err := json.Unmarshal(got, &msg); err != nil {
29+ t.Fatalf("unmarshal envelope: %v", err)
30+ }
31+
32+ if msg.Type != "history_timeline" {
33+ t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline")
34+ }
35+
36+ if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` {
37+ t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`)
38+ }
39+}
40+
41+func TestClientSendPrefersNativeTransport(t *testing.T) {
42+ t.Parallel()
43+
44+ native := &fakeTransport{available: true, response: []byte(`{"transport":"native"}`)}
45+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
46+
47+ client := NewClient(native, fetch)
48+ got, err := client.Send(context.Background(), "favorite_list", struct {
49+ RequestID string `json:"requestId"`
50+ }{RequestID: "req-2"})
51+ if err != nil {
52+ t.Fatalf("Send() error = %v", err)
53+ }
54+
55+ if string(got) != `{"transport":"native"}` {
56+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`)
57+ }
58+
59+ if !native.called {
60+ t.Fatal("native transport was not used")
61+ }
62+ if fetch.called {
63+ t.Fatal("fetch transport was used unexpectedly")
64+ }
65+
66+ var msg port.WebUIMessage
67+ if err := json.Unmarshal(native.last, &msg); err != nil {
68+ t.Fatalf("unmarshal sent envelope: %v", err)
69+ }
70+ if msg.Type != "favorite_list" {
71+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
72+ }
73+}
74+
75+func TestClientSendFallsBackToFetchTransport(t *testing.T) {
76+ t.Parallel()
77+
78+ native := &fakeTransport{available: false, response: []byte(`{"transport":"native"}`)}
79+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
80+
81+ client := NewClient(native, fetch)
82+ got, err := client.Send(context.Background(), "favorite_list", struct {
83+ RequestID string `json:"requestId"`
84+ }{RequestID: "req-3"})
85+ if err != nil {
86+ t.Fatalf("Send() error = %v", err)
87+ }
88+
89+ if string(got) != `{"transport":"fetch"}` {
90+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`)
91+ }
92+
93+ if native.called {
94+ t.Fatal("native transport was used unexpectedly")
95+ }
96+ if !fetch.called {
97+ t.Fatal("fetch transport was not used")
98+ }
99+}
100+
101+func TestClientTimelineDecodesEntries(t *testing.T) {
102+ t.Parallel()
103+
104+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
105+ client := NewClient(native, nil)
106+
107+ entries, err := client.Timeline(context.Background(), 25, 0)
108+ if err != nil {
109+ t.Fatalf("Timeline() error = %v", err)
110+ }
111+ if len(entries) != 1 {
112+ t.Fatalf("Timeline() len = %d, want 1", len(entries))
113+ }
114+ if entries[0].URL != "https://example.com" || entries[0].Title != "Example" {
115+ t.Fatalf("Timeline() entry = %+v", entries[0])
116+ }
117+
118+ var msg port.WebUIMessage
119+ if err := json.Unmarshal(native.last, &msg); err != nil {
120+ t.Fatalf("unmarshal sent envelope: %v", err)
121+ }
122+ if msg.Type != "history_timeline" {
123+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline")
124+ }
125+ if gotPayload := string(msg.Payload); gotPayload == "" {
126+ t.Fatal("sent payload was empty")
127+ }
128+}
129+
130+func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
131+ t.Parallel()
132+
133+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-window","success":true}`)}
134+ client := NewClient(native, nil)
135+
136+ _, err := client.TimelineWindow(context.Background(), time.Time{}, "")
137+ if err != nil {
138+ t.Fatalf("TimelineWindow() error = %v", err)
139+ }
140+
141+ var msg port.WebUIMessage
142+ if err := json.Unmarshal(native.last, &msg); err != nil {
143+ t.Fatalf("unmarshal sent envelope: %v", err)
144+ }
145+ if msg.Type != "history_timeline_window" {
146+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window")
147+ }
148+ var payload map[string]any
149+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
150+ t.Fatalf("unmarshal payload: %v", err)
151+ }
152+ if _, ok := payload["before"]; ok {
153+ t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload)
154+ }
155+}
156+
157+func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) {
158+ t.Parallel()
159+
160+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)}
161+ client := NewClient(transport, nil)
162+
163+ err := client.DeleteRange(context.Background(), " ")
164+ if err == nil {
165+ t.Fatal("DeleteRange() error = nil, want validation error")
166+ }
167+ if transport.called {
168+ t.Fatal("DeleteRange() sent a request for an invalid range")
169+ }
170+}
171+
172+func TestClientDeleteRangeSendsRange(t *testing.T) {
173+ t.Parallel()
174+
175+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-10","success":true}`)}
176+ client := NewClient(native, nil)
177+
178+ if err := client.DeleteRange(context.Background(), "week"); err != nil {
179+ t.Fatalf("DeleteRange() error = %v", err)
180+ }
181+
182+ var msg port.WebUIMessage
183+ if err := json.Unmarshal(native.last, &msg); err != nil {
184+ t.Fatalf("unmarshal sent envelope: %v", err)
185+ }
186+ if msg.Type != "history_delete_range" {
187+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range")
188+ }
189+}
190+
191+func TestClientListDecodesFavorites(t *testing.T) {
192+ t.Parallel()
193+
194+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
195+ client := NewClient(native, nil)
196+
197+ favorites, err := client.List(context.Background())
198+ if err != nil {
199+ t.Fatalf("List() error = %v", err)
200+ }
201+ if len(favorites) != 1 {
202+ t.Fatalf("List() len = %d, want 1", len(favorites))
203+ }
204+ if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" {
205+ t.Fatalf("List() favorite = %+v", favorites[0])
206+ }
207+
208+ var msg port.WebUIMessage
209+ if err := json.Unmarshal(native.last, &msg); err != nil {
210+ t.Fatalf("unmarshal sent envelope: %v", err)
211+ }
212+ if msg.Type != "favorite_list" {
213+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
214+ }
215+}
216+
217+func TestClientListFoldersDecodesFolders(t *testing.T) {
218+ t.Parallel()
219+
220+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)}
221+ client := NewClient(native, nil)
222+
223+ folders, err := client.ListFolders(context.Background())
224+ if err != nil {
225+ t.Fatalf("ListFolders() error = %v", err)
226+ }
227+ if len(folders) != 1 {
228+ t.Fatalf("ListFolders() len = %d, want 1", len(folders))
229+ }
230+ if folders[0].Name != "Read Later" {
231+ t.Fatalf("ListFolders() folder = %+v", folders[0])
232+ }
233+
234+ var msg port.WebUIMessage
235+ if err := json.Unmarshal(native.last, &msg); err != nil {
236+ t.Fatalf("unmarshal sent envelope: %v", err)
237+ }
238+ if msg.Type != "folder_list" {
239+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
240+ }
241+}
242+
243+func TestClientListTagsDecodesTags(t *testing.T) {
244+ t.Parallel()
245+
246+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`)}
247+ client := NewClient(native, nil)
248+
249+ tags, err := client.ListTags(context.Background())
250+ if err != nil {
251+ t.Fatalf("ListTags() error = %v", err)
252+ }
253+ if len(tags) != 1 {
254+ t.Fatalf("ListTags() len = %d, want 1", len(tags))
255+ }
256+ if tags[0].Name != "Go" || tags[0].Color != "#00ff00" {
257+ t.Fatalf("ListTags() tag = %+v", tags[0])
258+ }
259+
260+ var msg port.WebUIMessage
261+ if err := json.Unmarshal(native.last, &msg); err != nil {
262+ t.Fatalf("unmarshal sent envelope: %v", err)
263+ }
264+ if msg.Type != "tag_list" {
265+ t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list")
266+ }
267+}
268+
269+func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) {
270+ t.Parallel()
271+
272+ resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`)
273+
274+ currentClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
275+ current, err := currentClient.Current(context.Background())
276+ if err != nil {
277+ t.Fatalf("Current() error = %v", err)
278+ }
279+ if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" {
280+ t.Fatalf("Current() = %+v", current)
281+ }
282+
283+ var msg port.WebUIMessage
284+ if unmarshalErr := json.Unmarshal(currentClient.native.(*fakeTransport).last, &msg); unmarshalErr != nil {
285+ t.Fatalf("unmarshal sent envelope: %v", unmarshalErr)
286+ }
287+ if msg.Type != "/api/config" {
288+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config")
289+ }
290+
291+ defaultClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
292+ defaultCfg, err := defaultClient.Default(context.Background())
293+ if err != nil {
294+ t.Fatalf("Default() error = %v", err)
295+ }
296+ if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" {
297+ t.Fatalf("Default() = %+v", defaultCfg)
298+ }
299+
300+ if err := json.Unmarshal(defaultClient.native.(*fakeTransport).last, &msg); err != nil {
301+ t.Fatalf("unmarshal sent envelope: %v", err)
302+ }
303+ if msg.Type != "/api/config/default" {
304+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default")
305+ }
306+}
307+
308+func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
309+ t.Parallel()
310+
311+ tests := []struct {
312+ name string
313+ call func(*testing.T, *Client) error
314+ want string
315+ payload any
316+ }{
317+ {
318+ name: "save config",
319+ call: func(_ *testing.T, client *Client) error {
320+ return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321+ },
322+ want: "save_config",
323+ payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
324+ },
325+ {
326+ name: "get keybindings",
327+ call: func(t *testing.T, client *Client) error {
328+ got, err := client.GetKeybindings(context.Background())
329+ if err != nil {
330+ return err
331+ }
332+ if len(got.Groups) != 1 || got.Groups[0].Mode != "default" {
333+ t.Fatalf("GetKeybindings() = %#v", got)
334+ }
335+ return nil
336+ },
337+ want: "get_keybindings",
338+ },
339+ {
340+ name: "set keybinding",
341+ call: func(t *testing.T, client *Client) error {
342+ got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
343+ if err != nil {
344+ return err
345+ }
346+ if len(got.Conflicts) != 0 {
347+ t.Fatalf("SetKeybinding() = %#v", got)
348+ }
349+ return nil
350+ },
351+ want: "set_keybinding",
352+ payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}},
353+ },
354+ {
355+ name: "reset keybinding",
356+ call: func(_ *testing.T, client *Client) error {
357+ return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358+ },
359+ want: "reset_keybinding",
360+ payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"},
361+ },
362+ {
363+ name: "reset all keybindings",
364+ call: func(_ *testing.T, client *Client) error {
365+ return client.ResetAllKeybindings(context.Background())
366+ },
367+ want: "reset_all_keybindings",
368+ },
369+ }
370+
371+ for _, tt := range tests {
372+ t.Run(tt.name, func(t *testing.T) {
373+ t.Parallel()
374+
375+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`)}
376+ if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" {
377+ transport.response = []byte(`{"requestId":"req-15","success":true}`)
378+ }
379+ if tt.name == "get keybindings" {
380+ transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`)
381+ }
382+ client := NewClient(transport, nil)
383+
384+ if err := tt.call(t, client); err != nil {
385+ t.Fatalf("call() error = %v", err)
386+ }
387+
388+ var msg port.WebUIMessage
389+ if err := json.Unmarshal(transport.last, &msg); err != nil {
390+ t.Fatalf("unmarshal sent envelope: %v", err)
391+ }
392+ if msg.Type != tt.want {
393+ t.Fatalf("sent type = %q, want %q", msg.Type, tt.want)
394+ }
395+ if tt.payload != nil && len(msg.Payload) == 0 {
396+ t.Fatal("sent payload was empty")
397+ }
398+ })
399+ }
400+}
401+
402+func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
403+ t.Parallel()
404+
405+ transport := &blockingTransport{available: true}
406+ client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond))
407+
408+ _, err := client.Timeline(context.Background(), 25, 0)
409+ if !errors.Is(err, context.DeadlineExceeded) {
410+ t.Fatalf("Timeline() error = %v, want context deadline exceeded", err)
411+ }
412+ if !transport.called {
413+ t.Fatal("transport was not called")
414+ }
415+}
416+
417+func TestClientCreateFolderIncludesParentID(t *testing.T) {
418+ t.Parallel()
419+
420+ parentID := int64(42)
421+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)}
422+ client := NewClient(transport, nil)
423+
424+ folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
425+ if err != nil {
426+ t.Fatalf("CreateFolder() error = %v", err)
427+ }
428+ if folder == nil {
429+ t.Fatal("CreateFolder() returned nil folder")
430+ }
431+ if folder.Name != "Nested" {
432+ t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
433+ }
434+ if folder.Icon != "📁" {
435+ t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
436+ }
437+ if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
438+ t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
439+ }
440+
441+ var msg port.WebUIMessage
442+ if err := json.Unmarshal(transport.last, &msg); err != nil {
443+ t.Fatalf("unmarshal sent envelope: %v", err)
444+ }
445+ if msg.Type != "folder_create" {
446+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
447+ }
448+
449+ var payload struct {
450+ RequestID string `json:"requestId"`
451+ Name string `json:"name"`
452+ Icon *string `json:"icon"`
453+ ParentID *int64 `json:"parent_id"`
454+ }
455+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
456+ t.Fatalf("unmarshal sent payload: %v", err)
457+ }
458+ if payload.ParentID == nil || *payload.ParentID != parentID {
459+ t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
460+ }
461+ if payload.Name != "Nested" {
462+ t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
463+ }
464+ if payload.Icon == nil || *payload.Icon != "📁" {
465+ t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
466+ }
467+ if payload.RequestID == "" {
468+ t.Fatal("sent requestId was empty")
469+ }
470+}
471+
472+func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
473+ t.Parallel()
474+
475+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)}
476+ client := NewClient(transport, nil)
477+
478+ _, err := client.DomainStats(context.Background(), -1)
479+ if err == nil {
480+ t.Fatal("DomainStats() error = nil, want validation error")
481+ }
482+ if transport.called {
483+ t.Fatal("DomainStats() sent a request for a negative limit")
484+ }
485+}
486+
487+func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
488+ t.Parallel()
489+
490+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)}
491+ client := NewClient(transport, nil)
492+
493+ err := client.DeleteDomain(context.Background(), " ")
494+ if err == nil {
495+ t.Fatal("DeleteDomain() error = nil, want validation error")
496+ }
497+ if transport.called {
498+ t.Fatal("DeleteDomain() sent a request for an empty domain")
499+ }
500+}
501+
502+func TestClientUpdateFolderTrimsIcon(t *testing.T) {
503+ t.Parallel()
504+
505+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","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+ if msg.Type != "folder_update" {
517+ t.Fatalf("sent type = %q, want folder_update", msg.Type)
518+ }
519+ var payload struct {
520+ Icon *string `json:"icon"`
521+ }
522+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
523+ t.Fatalf("unmarshal sent payload: %v", err)
524+ }
525+ if payload.Icon == nil || *payload.Icon != "📚" {
526+ t.Fatalf("sent icon = %#v, want 📚", payload.Icon)
527+ }
528+}
529+
530+func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) {
531+ t.Parallel()
532+
533+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)}
534+ client := NewClient(transport, nil)
535+
536+ if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil {
537+ t.Fatalf("UpdateFolder() error = %v", err)
538+ }
539+
540+ var msg port.WebUIMessage
541+ if err := json.Unmarshal(transport.last, &msg); err != nil {
542+ t.Fatalf("unmarshal sent envelope: %v", err)
543+ }
544+ var payload struct {
545+ Icon *string `json:"icon"`
546+ }
547+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
548+ t.Fatalf("unmarshal sent payload: %v", err)
549+ }
550+ if payload.Icon != nil {
551+ t.Fatalf("sent icon = %#v, want nil", payload.Icon)
552+ }
553+}
554+
555+func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
556+ t.Parallel()
557+
558+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)}
559+ client := NewClient(transport, nil)
560+
561+ err := client.DeleteEntry(context.Background(), 0)
562+ if err == nil {
563+ t.Fatal("DeleteEntry() error = nil, want validation error")
564+ }
565+ if transport.called {
566+ t.Fatal("DeleteEntry() sent a request for an invalid id")
567+ }
568+}
569+
570+func TestClientTagActionsTrimFields(t *testing.T) {
571+ t.Parallel()
572+
573+ createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)}
574+ client := NewClient(createTransport, nil)
575+ if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil {
576+ t.Fatalf("CreateTag() error = %v", err)
577+ }
578+ var msg port.WebUIMessage
579+ if err := json.Unmarshal(createTransport.last, &msg); err != nil {
580+ t.Fatalf("unmarshal create envelope: %v", err)
581+ }
582+ var createPayload struct {
583+ Name string `json:"name"`
584+ Color *string `json:"color"`
585+ }
586+ if err := json.Unmarshal(msg.Payload, &createPayload); err != nil {
587+ t.Fatalf("unmarshal create payload: %v", err)
588+ }
589+ if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" {
590+ t.Fatalf("CreateTag payload = %#v", createPayload)
591+ }
592+
593+ updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)}
594+ client = NewClient(updateTransport, nil)
595+ if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil {
596+ t.Fatalf("UpdateTag() error = %v", err)
597+ }
598+ if err := json.Unmarshal(updateTransport.last, &msg); err != nil {
599+ t.Fatalf("unmarshal update envelope: %v", err)
600+ }
601+ var updatePayload struct {
602+ Name *string `json:"name"`
603+ Color *string `json:"color"`
604+ }
605+ if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil {
606+ t.Fatalf("unmarshal update payload: %v", err)
607+ }
608+ if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil {
609+ t.Fatalf("UpdateTag payload = %#v", updatePayload)
610+ }
611+}
612+
613+type fakeTransport struct {
614+ available bool
615+ called bool
616+ last []byte
617+ response []byte
618+}
619+
620+func (t *fakeTransport) Available() bool { return t.available }
621+
622+func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
623+ t.called = true
624+ t.last = append(t.last[:0], body...)
625+ return t.response, nil
626+}
627+
628+type blockingTransport struct {
629+ available bool
630+ called bool
631+}
632+
633+func (t *blockingTransport) Available() bool { return t.available }
634+
635+func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) {
636+ t.called = true
637+ <-ctx.Done()
638+ return nil, ctx.Err()
639+}
640+
641+func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
642+ t.Parallel()
643+
644+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)}
645+ client := NewClient(transport, nil)
646+
647+ checks := []struct {
648+ name string
649+ call func() error
650+ }{
651+ {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
652+ {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},
653+ {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }},
654+ {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }},
655+ {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }},
656+ {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }},
657+ {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
658+ {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
659+ {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
660+ {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
661+ {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
662+ {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
663+ {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
664+ {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }},
665+ {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }},
666+ }
667+ for _, check := range checks {
668+ t.Run(check.name, func(t *testing.T) {
669+ if err := check.call(); err == nil {
670+ t.Fatal("error = nil, want validation error")
671+ }
672+ })
673+ }
674+ if transport.called {
675+ t.Fatal("transport called for invalid favorite mutation IDs")
676+ }
677+}
678+
679+func TestClientRejectsNegativeHistoryLimits(t *testing.T) {
680+ t.Parallel()
681+
682+ client := NewClient(&fakeTransport{available: true}, nil)
683+
684+ if _, err := client.Timeline(context.Background(), -1, 0); err == nil {
685+ t.Fatal("Timeline() error = nil, want error")
686+ }
687+ if _, err := client.Timeline(context.Background(), 1, -1); err == nil {
688+ t.Fatal("Timeline() error = nil, want error")
689+ }
690+ if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil {
691+ t.Fatal("TimelineByDomain() error = nil, want error")
692+ }
693+ if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil {
694+ t.Fatal("TimelineByDomain() error = nil, want error")
695+ }
696+ if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil {
697+ t.Fatal("TimelineByDomain() error = nil, want error")
698+ }
699+ if _, err := client.Search(context.Background(), "example", -1); err == nil {
700+ t.Fatal("Search() error = nil, want error")
701+ }
702+}
M internal/infrastructure/transcoder/audio.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
M internal/infrastructure/transcoder/ffmpeg_offsets.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 // Offset-based accessors for FFmpeg struct fields not yet exposed5 // Offset-based accessors for FFmpeg struct fields not yet exposed
A internal/infrastructure/transcoder/http_client_test.go +138 -0
@@ -0,0 +1,138 @@
1+//go:build !js || !wasm
2+
3+package transcoder
4+
5+import (
6+ "context"
7+ "net"
8+ "net/http"
9+ "net/url"
10+ "testing"
11+
12+ "github.com/rs/zerolog"
13+
14+ "github.com/bnema/dumber/internal/application/port"
15+)
16+
17+func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) {
18+ source, err := url.Parse("http://127.0.0.1/video.mp4")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+
23+ err = validateHTTPTranscodeURL(context.Background(), source)
24+ if err == nil {
25+ t.Fatal("validateHTTPTranscodeURL() expected error for loopback target")
26+ }
27+}
28+
29+func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
30+ tests := []string{
31+ "100.64.0.1",
32+ "198.18.0.1",
33+ "192.0.2.1",
34+ "64:ff9b::a00:1",
35+ "2001::1",
36+ "2001:db8::1",
37+ "2002:0a00:0001::1",
38+ }
39+ for _, rawIP := range tests {
40+ t.Run(rawIP, func(t *testing.T) {
41+ if !isBlockedTranscodeIP(net.ParseIP(rawIP)) {
42+ t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP)
43+ }
44+ })
45+ }
46+}
47+
48+func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) {
49+ source, err := url.Parse("file:///etc/passwd")
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ err = validateHTTPTranscodeURL(context.Background(), source)
55+ if err == nil {
56+ t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme")
57+ }
58+}
59+
60+func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) {
61+ source, err := url.Parse("https://93.184.216.34/playlist.m3u8")
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ err = validateHTTPTranscodeURL(context.Background(), source)
67+ if err == nil {
68+ t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest")
69+ }
70+}
71+
72+func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) {
73+ source, err := url.Parse("https://93.184.216.34/video.mp4")
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+
78+ if err := validateHTTPTranscodeURL(context.Background(), source); err != nil {
79+ t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err)
80+ }
81+}
82+
83+func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) {
84+ p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop())
85+
86+ _, _, err := p.openInputFormatContext(context.Background())
87+ if err == nil {
88+ t.Fatal("openInputFormatContext() expected error for streaming manifest")
89+ }
90+}
91+
92+func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) {
93+ resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}}
94+ if !isStreamingManifestResponse(resp) {
95+ t.Fatal("isStreamingManifestResponse() = false, want true")
96+ }
97+}
98+
99+func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) {
100+ requestURL, err := url.Parse("http://10.0.0.1/private.mp4")
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+
105+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil)
106+ if err == nil {
107+ t.Fatal("validateTranscodeRedirect() expected error for private redirect target")
108+ }
109+}
110+
111+func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) {
112+ requestURL, err := url.Parse("https://93.184.216.34/video.mp4")
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+
117+ via := make([]*http.Request, 10)
118+ for i := range via {
119+ via[i] = &http.Request{URL: requestURL}
120+ }
121+
122+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via)
123+ if err == nil {
124+ t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect")
125+ }
126+}
127+
128+func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) {
129+ requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8")
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+
134+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil)
135+ if err == nil {
136+ t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target")
137+ }
138+}
M internal/infrastructure/transcoder/hwprobe.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
M internal/infrastructure/transcoder/hwprobe_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import "testing"5 import "testing"
M internal/infrastructure/transcoder/pipeline.go +112 -110
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
@@ -5,13 +7,12 @@ import (
5 "errors"7 "errors"
6 "fmt"8 "fmt"
7 "io"9 "io"
10+ "net"
8 "net/http"11 "net/http"
9- "sort"12+ "net/url"
10 "strings"13 "strings"
11- "unsafe"
12-
13- "net"
14 "time"14 "time"
15+ "unsafe"
15 16
16 "github.com/bnema/purego"17 "github.com/bnema/purego"
17 "github.com/rs/zerolog"18 "github.com/rs/zerolog"
@@ -19,6 +20,7 @@ import (
19 "github.com/bnema/purego-ffmpeg/ffmpeg"20 "github.com/bnema/purego-ffmpeg/ffmpeg"
20 21
21 "github.com/bnema/dumber/internal/application/port"22 "github.com/bnema/dumber/internal/application/port"
23+ "github.com/bnema/dumber/internal/infrastructure/netguard"
22 )24 )
23 25
24 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).26 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
@@ -53,10 +55,104 @@ type pipeline struct {
53 // are not killed. The session context handles cancellation.55 // are not killed. The session context handles cancellation.
54 var httpClient = &http.Client{56 var httpClient = &http.Client{
55 Transport: &http.Transport{57 Transport: &http.Transport{
56- DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,58+ DialContext: safeTranscodeDialContext,
57 TLSHandshakeTimeout: 10 * time.Second,59 TLSHandshakeTimeout: 10 * time.Second,
58 ResponseHeaderTimeout: 15 * time.Second,60 ResponseHeaderTimeout: 15 * time.Second,
59 },61 },
62+ CheckRedirect: validateTranscodeRedirect,
63+}
64+
65+func validateTranscodeRedirect(req *http.Request, via []*http.Request) error {
66+ if len(via) >= 10 {
67+ return fmt.Errorf("stopped after 10 redirects")
68+ }
69+ if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil {
70+ return fmt.Errorf("blocked redirect target: %w", err)
71+ }
72+ return nil
73+}
74+
75+func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
76+ host, portValue, err := net.SplitHostPort(address)
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ ips, err := resolveAllowedTranscodeIPs(ctx, host)
82+ if err != nil {
83+ return nil, err
84+ }
85+
86+ dialer := &net.Dialer{Timeout: 10 * time.Second}
87+ var lastErr error
88+ for _, ip := range ips {
89+ conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue))
90+ if dialErr == nil {
91+ return conn, nil
92+ }
93+ lastErr = dialErr
94+ }
95+ if lastErr != nil {
96+ return nil, lastErr
97+ }
98+ return nil, fmt.Errorf("no allowed transcode source addresses for %s", host)
99+}
100+
101+func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error {
102+ if source == nil || source.Host == "" {
103+ return fmt.Errorf("invalid src")
104+ }
105+ if source.Scheme != "http" && source.Scheme != "https" {
106+ return fmt.Errorf("unsupported src scheme")
107+ }
108+ if IsStreamingManifestURL(source.String()) {
109+ return fmt.Errorf("streaming manifest transcoding is disabled")
110+ }
111+ _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname())
112+ return err
113+}
114+
115+func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) {
116+ host = strings.TrimSpace(host)
117+ if host == "" {
118+ return nil, fmt.Errorf("invalid src host")
119+ }
120+ if ip := net.ParseIP(host); ip != nil {
121+ if isBlockedTranscodeIP(ip) {
122+ return nil, fmt.Errorf("private src host not allowed")
123+ }
124+ return []net.IP{ip}, nil
125+ }
126+
127+ lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
128+ defer cancel()
129+ addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
130+ if err != nil || len(addrs) == 0 {
131+ return nil, fmt.Errorf("src host could not be resolved")
132+ }
133+ ips := make([]net.IP, 0, len(addrs))
134+ for _, addr := range addrs {
135+ if isBlockedTranscodeIP(addr.IP) {
136+ return nil, fmt.Errorf("private src host not allowed")
137+ }
138+ ips = append(ips, addr.IP)
139+ }
140+ return ips, nil
141+}
142+
143+func isBlockedTranscodeIP(ip net.IP) bool {
144+ return netguard.IsBlockedTranscodeIP(ip)
145+}
146+
147+func isStreamingManifestResponse(resp *http.Response) bool {
148+ if resp == nil {
149+ return false
150+ }
151+ if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) {
152+ return true
153+ }
154+ contentType := strings.ToLower(resp.Header.Get("Content-Type"))
155+ return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml")
60 }156 }
61 157
62 func newPipeline(158 func newPipeline(
@@ -394,7 +490,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI
394 490
395 func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {491 func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {
396 if IsStreamingManifestURL(p.sourceURL) {492 if IsStreamingManifestURL(p.sourceURL) {
397- return p.openManifestInputContext()493+ // FFmpeg resolves manifest redirects and segment URLs internally, bypassing
494+ // the guarded Go HTTP client below. Keep untrusted transcode requests on
495+ // custom I/O until manifests can be fetched and rewritten through the same
496+ // SSRF protections.
497+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
398 }498 }
399 return p.openCustomIOInputContext(ctx)499 return p.openCustomIOInputContext(ctx)
400 }500 }
@@ -417,6 +517,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
417 _ = resp.Body.Close()517 _ = resp.Body.Close()
418 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)518 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)
419 }519 }
520+ if isStreamingManifestResponse(resp) {
521+ _ = resp.Body.Close()
522+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
523+ }
420 524
421 body := resp.Body525 body := resp.Body
422 readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {526 readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
@@ -478,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
478 return inFmtCtx, cleanup, nil582 return inFmtCtx, cleanup, nil
479 }583 }
480 584
481-func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) {
482- inFmtCtx := ffmpeg.FormatAllocContext()
483- if inFmtCtx == nil {
484- return nil, nil, errors.New("failed to allocate input format context")
485- }
486-
487- var opts unsafe.Pointer
488- defer freeInputOptions(&opts)
489-
490- if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil {
491- ffmpeg.FormatFreeContext(inFmtCtx)
492- return nil, nil, err
493- }
494- if userAgent := p.headers["User-Agent"]; userAgent != "" {
495- if err := setInputOption(&opts, "user_agent", userAgent); err != nil {
496- ffmpeg.FormatFreeContext(inFmtCtx)
497- return nil, nil, err
498- }
499- }
500- if referer := p.headers["Referer"]; referer != "" {
501- if err := setInputOption(&opts, "referer", referer); err != nil {
502- ffmpeg.FormatFreeContext(inFmtCtx)
503- return nil, nil, err
504- }
505- }
506-
507- headerLines := formatHTTPHeaderOptions(p.headers)
508- if headerLines != "" {
509- if err := setInputOption(&opts, "headers", headerLines); err != nil {
510- ffmpeg.FormatFreeContext(inFmtCtx)
511- return nil, nil, err
512- }
513- }
514-
515- //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries.
516- if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 {
517- ffmpeg.FormatFreeContext(inFmtCtx)
518- return nil, nil, fmt.Errorf("open manifest input: %d", ret)
519- }
520-
521- return inFmtCtx, func() {
522- //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
523- ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
524- }, nil
525-}
526-
527-func freeInputOptions(opts *unsafe.Pointer) {
528- if opts == nil || *opts == nil {
529- return
530- }
531- // av_dict_free expects an AVDictionary**. Passing the dictionary pointer
532- // itself corrupts memory and can crash inside libavutil during cleanup.
533- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup.
534- ffmpeg.DictFree(unsafe.Pointer(opts))
535- *opts = nil
536-}
537-
538-func setInputOption(opts *unsafe.Pointer, key, value string) error {
539- if value == "" {
540- return nil
541- }
542- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates.
543- if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 {
544- return fmt.Errorf("set input option %q: %d", key, ret)
545- }
546- return nil
547-}
548-
549-func formatHTTPHeaderOptions(headers map[string]string) string {
550- if len(headers) == 0 {
551- return ""
552- }
553-
554- keys := make([]string, 0, len(headers))
555- for key, value := range headers {
556- if strings.TrimSpace(value) == "" {
557- continue
558- }
559- if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") {
560- continue
561- }
562- keys = append(keys, key)
563- }
564- if len(keys) == 0 {
565- return ""
566- }
567-
568- sort.Strings(keys)
569-
570- var builder strings.Builder
571- for _, key := range keys {
572- builder.WriteString(key)
573- builder.WriteString(": ")
574- builder.WriteString(headers[key])
575- builder.WriteString("\r\n")
576- }
577- return builder.String()
578-}
579-
580-// openVideoDecoder creates and opens a decoder context for the video stream.
581-// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
582-// falling back to the generic software decoder.
583-//
584-//nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup.
585 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {585 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
586 codecID := ffmpeg.CodecParCodecID(codecPar)586 codecID := ffmpeg.CodecParCodecID(codecPar)
587 587
@@ -608,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er
608 }608 }
609 609
610 if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {610 if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
611+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
611 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))612 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
612 return nil, fmt.Errorf("copy video codec params: %d", ret)613 return nil, fmt.Errorf("copy video codec params: %d", ret)
613 }614 }
614 615
615 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {616 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
617+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
616 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))618 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
617 return nil, fmt.Errorf("open video decoder: %d", ret)619 return nil, fmt.Errorf("open video decoder: %d", ret)
618 }620 }
M internal/infrastructure/transcoder/transcoder.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
1 package transcoder3 package transcoder
2 4
3 import (5 import (
A internal/infrastructure/transcoder/transcoder_wasm.go +41 -0
@@ -0,0 +1,41 @@
1+//go:build js && wasm
2+
3+package transcoder
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: Transcoder implements port.MediaTranscoder.
15+var _ port.MediaTranscoder = (*Transcoder)(nil)
16+
17+// Transcoder is unavailable in js/wasm builds because FFmpeg is not available.
18+type Transcoder struct{}
19+
20+// New creates an unavailable Transcoder for js/wasm builds.
21+func New(_ any, _ *zerolog.Logger) *Transcoder {
22+ return &Transcoder{}
23+}
24+
25+// Available reports whether transcoding is available.
26+func (*Transcoder) Available() bool {
27+ return false
28+}
29+
30+// Capabilities returns empty hardware capabilities for js/wasm builds.
31+func (*Transcoder) Capabilities() port.HWCapabilities {
32+ return port.HWCapabilities{}
33+}
34+
35+// Start always fails for js/wasm builds.
36+func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) {
37+ return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm")
38+}
39+
40+// Close is a no-op for js/wasm builds.
41+func (*Transcoder) Close() {}
M internal/infrastructure/webkit/engine_init.go +3 -0
@@ -25,6 +25,8 @@ func NewEngine(
25 opts port.EngineOptions,25 opts port.EngineOptions,
26 profile runtimeprofile.Profile,26 profile runtimeprofile.Profile,
27 wkCfg WebKitEngineConfig,27 wkCfg WebKitEngineConfig,
28+ currentConfigPayload func() ([]byte, error),
29+ defaultConfigPayload func() ([]byte, error),
28 themeManager *theme.Manager,30 themeManager *theme.Manager,
29 colorResolver port.ColorSchemeResolver,31 colorResolver port.ColorSchemeResolver,
30 contextMenuBuilder port.ContextMenuBuilder,32 contextMenuBuilder port.ContextMenuBuilder,
@@ -56,6 +58,7 @@ func NewEngine(
56 58
57 // --- Scheme handler ---59 // --- Scheme handler ---
58 schemeHandler := NewDumbSchemeHandler(ctx)60 schemeHandler := NewDumbSchemeHandler(ctx)
61+ schemeHandler.SetConfigPayloadBuilders(currentConfigPayload, defaultConfigPayload)
59 schemeHandler.SetAssets(assets.WebUIAssets)62 schemeHandler.SetAssets(assets.WebUIAssets)
60 schemeHandler.RegisterWithContext(wkCtx)63 schemeHandler.RegisterWithContext(wkCtx)
61 64
A internal/infrastructure/webkit/favicon_systemviews.go +14 -0
@@ -0,0 +1,14 @@
1+package webkit
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)
6+
7+// SetSystemviewFaviconService exposes cached favicons to systemview pages via
8+// dumb://api/favicon without triggering remote favicon fetches during History rendering.
9+func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {
10+ if e == nil || e.schemeHandler == nil {
11+ return
12+ }
13+ e.schemeHandler.SetFaviconService(service)
14+}
M internal/infrastructure/webkit/scheme_handler.go +314 -87
@@ -1,20 +1,23 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "embed"6 "embed"
6- "encoding/json"
7 "fmt"7 "fmt"
8+ "io"
8 "io/fs"9 "io/fs"
9 "net/http"10 "net/http"
10 "net/url"11 "net/url"
11- "path/filepath"12+ "os"
13+ "path"
14+ "strconv"
12 "strings"15 "strings"
13 "sync"16 "sync"
14 17
18+ "github.com/andybalholm/brotli"
15 "github.com/bnema/dumber/internal/application/port"19 "github.com/bnema/dumber/internal/application/port"
16- "github.com/bnema/dumber/internal/infrastructure/config"20+ domainurl "github.com/bnema/dumber/internal/domain/url"
17- "github.com/bnema/dumber/internal/infrastructure/env"
18 "github.com/bnema/dumber/internal/infrastructure/webutil"21 "github.com/bnema/dumber/internal/infrastructure/webutil"
19 "github.com/bnema/dumber/internal/logging"22 "github.com/bnema/dumber/internal/logging"
20 "github.com/bnema/puregotk/v4/gio"23 "github.com/bnema/puregotk/v4/gio"
@@ -25,29 +28,35 @@ import (
25 28
26 // Scheme path constants29 // Scheme path constants
27 const (30 const (
28- HomePath = "home"31+ HistoryPath = "history"
29- ConfigPath = "config"32+ FavoritesPath = "favorites"
30- WebRTCPath = "webrtc"33+ ConfigPath = "config"
31- ErrorPath = "error"34+ ErrorPath = "error"
32- CrashPath = "crash"35+ CrashPath = "crash"
33- IndexHTML = "index.html"36+ IndexHTML = "index.html"
34- httpGET = "GET"37+ httpGET = "GET"
38+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
39+ systemviewsAssetDir = "systemviews"
35 )40 )
36 41
37 // SchemeRequest represents a request to a custom URI scheme.42 // SchemeRequest represents a request to a custom URI scheme.
38 type SchemeRequest struct {43 type SchemeRequest struct {
39- inner *webkit.URISchemeRequest44+ inner *webkit.URISchemeRequest
40- URI string45+ URI string
41- Path string46+ Path string
42- Method string47+ Method string
43- Scheme string48+ Scheme string
49+ Origin string
50+ Referer string
44 }51 }
45 52
46 // SchemeResponse represents a response to a scheme request.53 // SchemeResponse represents a response to a scheme request.
47 type SchemeResponse struct {54 type SchemeResponse struct {
48- Data []byte55+ Data []byte
49- ContentType string56+ ContentType string
50- StatusCode int57+ StatusCode int
58+ Headers map[string]string
59+ SuppressDefaultHeaders bool
51 }60 }
52 61
53 // PageHandler generates content for a specific page path.62 // PageHandler generates content for a specific page path.
@@ -64,13 +73,14 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
64 73
65 // DumbSchemeHandler handles dumb:// URI scheme requests.74 // DumbSchemeHandler handles dumb:// URI scheme requests.
66 type DumbSchemeHandler struct {75 type DumbSchemeHandler struct {
67- handlers map[string]PageHandler76+ handlers map[string]PageHandler
68- assets embed.FS77+ assets embed.FS
69- assetDir string // subdirectory within embed.FS (e.g., "assets/webui")78+ faviconService port.FaviconService
70- logger zerolog.Logger79+ assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
71- mu sync.RWMutex80+ logger zerolog.Logger
72- hwSurveyor *env.HardwareSurveyor81+ mu sync.RWMutex
73- ctx context.Context82+ currentConfigPayload func() ([]byte, error)
83+ defaultConfigPayload func() ([]byte, error)
74 }84 }
75 85
76 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.86 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
@@ -78,11 +88,9 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
78 log := logging.FromContext(ctx)88 log := logging.FromContext(ctx)
79 89
80 h := &DumbSchemeHandler{90 h := &DumbSchemeHandler{
81- handlers: make(map[string]PageHandler),91+ handlers: make(map[string]PageHandler),
82- assetDir: "webui",92+ assetDir: systemviewsAssetDir,
83- logger: log.With().Str("component", "scheme-handler").Logger(),93+ logger: log.With().Str("component", "scheme-handler").Logger(),
84- hwSurveyor: env.NewHardwareSurveyor(),
85- ctx: ctx,
86 }94 }
87 95
88 // Register default pages96 // Register default pages
@@ -91,7 +99,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
91 return h99 return h
92 }100 }
93 101
94-// SetAssets sets the embedded filesystem containing webui assets.102+// SetAssets sets the embedded filesystem containing systemviews assets.
95 func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {103 func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
96 h.mu.Lock()104 h.mu.Lock()
97 defer h.mu.Unlock()105 defer h.mu.Unlock()
@@ -99,6 +107,21 @@ func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
99 h.logger.Debug().Msg("assets filesystem configured")107 h.logger.Debug().Msg("assets filesystem configured")
100 }108 }
101 109
110+// SetConfigPayloadBuilders wires the config payload builders used by /api/config.
111+func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) {
112+ h.mu.Lock()
113+ defer h.mu.Unlock()
114+ h.currentConfigPayload = current
115+ h.defaultConfigPayload = defaultPayload
116+ h.logger.Debug().Msg("config payload builders configured")
117+}
118+
119+func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {
120+ h.mu.Lock()
121+ defer h.mu.Unlock()
122+ h.faviconService = service
123+}
124+
102 // registerDefaults sets up default page handlers.125 // registerDefaults sets up default page handlers.
103 func (h *DumbSchemeHandler) registerDefaults() {126 func (h *DumbSchemeHandler) registerDefaults() {
104 // Error page (static fallback)127 // Error page (static fallback)
@@ -128,8 +151,14 @@ func (h *DumbSchemeHandler) registerDefaults() {
128 if req.Method != "" && req.Method != httpGET {151 if req.Method != "" && req.Method != httpGET {
129 return nil152 return nil
130 }153 }
154+ if !isTrustedSystemviewAPIRequest(req) {
155+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
156+ }
131 157
132- return h.buildConfigResponse(config.Get())158+ h.mu.RLock()
159+ build := h.currentConfigPayload
160+ h.mu.RUnlock()
161+ return buildConfigResponse(build)
133 }))162 }))
134 163
135 // API: Get default config (used by Reset Defaults in dumb://config)164 // API: Get default config (used by Reset Defaults in dumb://config)
@@ -137,8 +166,21 @@ func (h *DumbSchemeHandler) registerDefaults() {
137 if req.Method != "" && req.Method != httpGET {166 if req.Method != "" && req.Method != httpGET {
138 return nil167 return nil
139 }168 }
169+ if !isTrustedSystemviewAPIRequest(req) {
170+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
171+ }
140 172
141- return h.buildConfigResponse(config.DefaultConfig())173+ h.mu.RLock()
174+ build := h.defaultConfigPayload
175+ h.mu.RUnlock()
176+ return buildConfigResponse(build)
177+ }))
178+
179+ h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
180+ if req.Method != "" && req.Method != httpGET {
181+ return nil
182+ }
183+ return h.handleFaviconAPI(req)
142 }))184 }))
143 }185 }
144 186
@@ -161,40 +203,138 @@ func buildCrashPageHTML(originalURI string) string {
161 return webutil.BuildCrashPageHTML(originalURI)203 return webutil.BuildCrashPageHTML(originalURI)
162 }204 }
163 205
164-func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {206+const systemviewFaviconSize = 32
165- // Get hardware info for display and profile resolution
166- // Use background context since survey results are cached and we don't want
167- // request context cancellation to affect this
168- var hw *port.HardwareInfo
169- if h.hwSurveyor != nil {
170- hwInfo := h.hwSurveyor.Survey(context.Background())
171- hw = &hwInfo
172- }
173-
174- resp := config.BuildWebUIConfigPayload(cfg, hw)
175 207
176- data, err := json.Marshal(resp)208+func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
209+ if !isTrustedSystemviewFaviconRequest(req) {
210+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211+ }
212+ h.mu.RLock()
213+ service := h.faviconService
214+ h.mu.RUnlock()
215+ if service == nil {
216+ return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217+ }
218+ if req == nil {
219+ return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
220+ }
221+ parsed, err := url.Parse(req.URI)
177 if err != nil {222 if err != nil {
178- return &SchemeResponse{223+ return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
179- Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),224+ }
180- ContentType: "application/json",225+ domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
181- StatusCode: http.StatusInternalServerError,226+ if domain == "" {
227+ return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228+ }
229+ size := systemviewFaviconSize
230+ if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
231+ parsedSize, parseErr := strconv.Atoi(rawSize)
232+ if parseErr != nil || parsedSize != systemviewFaviconSize {
233+ return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size")
182 }234 }
235+ size = parsedSize
183 }236 }
184 237
238+ if !service.HasPNGSizedOnDisk(domain, size) {
239+ return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240+ }
241+ diskPath := service.DiskPathPNGSized(domain, size)
242+ if diskPath == "" {
243+ return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244+ }
245+ data, err := os.ReadFile(diskPath)
246+ if err != nil || len(data) == 0 {
247+ return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
248+ }
185 return &SchemeResponse{249 return &SchemeResponse{
186- Data: data,250+ Data: data,
251+ ContentType: "image/png",
252+ StatusCode: http.StatusOK,
253+ Headers: map[string]string{"Cache-Control": "no-store"},
254+ SuppressDefaultHeaders: true,
255+ }
256+}
257+
258+func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
259+ return isTrustedSystemviewAPIRequest(req)
260+}
261+
262+func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
263+ if req == nil {
264+ return false
265+ }
266+ origin := strings.TrimSpace(req.Origin)
267+ if origin != "" {
268+ return isTrustedSystemviewURL(origin)
269+ }
270+ return isTrustedSystemviewURL(req.Referer)
271+}
272+
273+func isTrustedSystemviewURL(raw string) bool {
274+ parsed, err := url.Parse(strings.TrimSpace(raw))
275+ if err != nil || parsed.Scheme == "" {
276+ return false
277+ }
278+ if !strings.EqualFold(parsed.Scheme, "dumb") {
279+ return false
280+ }
281+ host := parsed.Host
282+ if host == "" {
283+ host = parsed.Opaque
284+ }
285+ if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
286+ host = host[:idx]
287+ }
288+ switch host {
289+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
290+ return true
291+ default:
292+ return false
293+ }
294+}
295+
296+func jsonErrorResponse(status int, message string) *SchemeResponse {
297+ return &SchemeResponse{
298+ Data: []byte(fmt.Sprintf(`{"error":%q}`, message)),
187 ContentType: "application/json",299 ContentType: "application/json",
188- StatusCode: http.StatusOK,300+ StatusCode: status,
301+ }
302+}
303+
304+func privateJSONErrorResponse(status int, message string) *SchemeResponse {
305+ resp := jsonErrorResponse(status, message)
306+ resp.Headers = map[string]string{"Cache-Control": "no-store"}
307+ resp.SuppressDefaultHeaders = true
308+ return resp
309+}
310+
311+func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
312+ if build == nil {
313+ resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured")
314+ return resp
315+ }
316+
317+ data, err := build()
318+ if err != nil {
319+ resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error())
320+ return resp
321+ }
322+
323+ return &SchemeResponse{
324+ Data: data,
325+ ContentType: "application/json",
326+ StatusCode: http.StatusOK,
327+ Headers: map[string]string{"Cache-Control": "no-store"},
328+ SuppressDefaultHeaders: true,
189 }329 }
190 }330 }
191 331
192 // RegisterPage registers a handler for a specific path.332 // RegisterPage registers a handler for a specific path.
193-func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {333+func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
194 h.mu.Lock()334 h.mu.Lock()
195 defer h.mu.Unlock()335 defer h.mu.Unlock()
196- h.handlers[path] = handler336+ h.handlers[pagePath] = handler
197- h.logger.Debug().Str("path", path).Msg("registered page handler")337+ h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
198 }338 }
199 339
200 // HandleRequest processes a scheme request and sends the response.340 // HandleRequest processes a scheme request and sends the response.
@@ -205,6 +345,7 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
205 }345 }
206 346
207 uri := req.GetUri()347 uri := req.GetUri()
348+ requestHeaders := req.GetHttpHeaders()
208 schemeReq := &SchemeRequest{349 schemeReq := &SchemeRequest{
209 inner: req,350 inner: req,
210 URI: uri,351 URI: uri,
@@ -212,6 +353,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
212 Method: req.GetHttpMethod(),353 Method: req.GetHttpMethod(),
213 Scheme: req.GetScheme(),354 Scheme: req.GetScheme(),
214 }355 }
356+ if requestHeaders != nil {
357+ schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
358+ schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
359+ }
215 360
216 h.logger.Debug().361 h.logger.Debug().
217 Str("uri", schemeReq.URI).362 Str("uri", schemeReq.URI).
@@ -279,21 +424,26 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
279 func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {424 func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
280 h.mu.RLock()425 h.mu.RLock()
281 hasAssets := h.assets != (embed.FS{})426 hasAssets := h.assets != (embed.FS{})
282- assetDir := h.assetDir
283 h.mu.RUnlock()427 h.mu.RUnlock()
284 428
285 if !hasAssets {429 if !hasAssets {
286 return nil430 return nil
287 }431 }
288 432
289- relPath, ok := resolveAssetPath(u)433+ assetDir, relPath, ok := resolveAssetPath(u)
434+ if !ok {
435+ return nil
436+ }
437+ if assetDir == "" {
438+ assetDir = h.assetDir
439+ }
440+
441+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
290 if !ok {442 if !ok {
291 return nil443 return nil
292 }444 }
293 445
294- // Read the asset from embedded FS446+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
295- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
296- data, err := fs.ReadFile(h.assets, fullPath)
297 if err != nil {447 if err != nil {
298 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")448 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
299 return nil449 return nil
@@ -310,43 +460,111 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
310 Data: data,460 Data: data,
311 ContentType: contentType,461 ContentType: contentType,
312 StatusCode: http.StatusOK,462 StatusCode: http.StatusOK,
463+ Headers: nil,
464+ }
465+}
466+
467+func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
468+ assetDir = strings.Trim(assetDir, "/")
469+ if assetDir != systemviewsAssetDir {
470+ return "", "", false
313 }471 }
472+
473+ relPath = strings.TrimLeft(relPath, "/")
474+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
475+ return "", "", false
476+ }
477+
478+ cleanRelPath = path.Clean(relPath)
479+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
480+ return "", "", false
481+ }
482+
483+ fullPath = path.Join(assetDir, cleanRelPath)
484+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
485+ return "", "", false
486+ }
487+ return fullPath, cleanRelPath, true
314 }488 }
315 489
316-func resolveAssetPath(u *url.URL) (string, bool) {490+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
491+ var compressedErr error
492+ if strings.HasSuffix(relPath, ".wasm") {
493+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
494+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
495+ if err != nil {
496+ compressedErr = err
497+ } else if len(data) > maxSystemviewsWASMBytes {
498+ compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
499+ } else {
500+ return data, nil
501+ }
502+ }
503+ }
504+ data, err := fs.ReadFile(assets, fullPath)
505+ if err == nil {
506+ return data, nil
507+ }
508+ if compressedErr != nil {
509+ return nil, compressedErr
510+ }
511+ return nil, err
512+}
513+
514+func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
317 if u == nil {515 if u == nil {
318- return "", false516+ return "", "", false
319 }517 }
320 518
321- rootByHost := map[string]string{519+ rootByHost := map[string]struct {
322- HomePath: IndexHTML,520+ assetDir string
323- ConfigPath: "config.html",521+ file string
324- WebRTCPath: "webrtc.html",522+ }{
325- ErrorPath: "error.html",523+ HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
524+ FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
525+ ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
526+ ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
527+ CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
326 }528 }
327 529
328 if root, ok := rootByHost[u.Host]; ok {530 if root, ok := rootByHost[u.Host]; ok {
329- path := strings.TrimPrefix(u.Path, "/")531+ assetPath := strings.TrimPrefix(u.Path, "/")
330- if path == "" {532+ if assetPath == "" {
331- return root, true533+ return root.assetDir, root.file, true
332 }534 }
333- return path, true535+ return root.assetDir, assetPath, true
334 }536 }
335 537
336 switch u.Opaque {538 switch u.Opaque {
337- case HomePath:539+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
338- return IndexHTML, true540+ return systemviewsAssetDir, IndexHTML, true
339- case ConfigPath:
340- return "config.html", true
341- case ErrorPath:
342- return "error.html", true
343- case WebRTCPath:
344- return "webrtc.html", true
345 default:541 default:
346- return "", false542+ return "", "", false
347 }543 }
348 }544 }
349 545
546+func shouldAddCORSHeaders(requestPath string) bool {
547+ requestPath = strings.TrimSpace(requestPath)
548+ return strings.HasSuffix(requestPath, ".wasm")
549+}
550+
551+func responseHeadersForPath(requestPath, contentType string) map[string]string {
552+ if !shouldAddCORSHeaders(requestPath) {
553+ return nil
554+ }
555+
556+ headers := map[string]string{
557+ "Access-Control-Allow-Origin": "*",
558+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
559+ "Access-Control-Allow-Headers": "Content-Type",
560+ "Access-Control-Max-Age": "86400",
561+ }
562+ if contentType != "" {
563+ headers["Content-Type"] = contentType
564+ }
565+ return headers
566+}
567+
350 // sendResponse sends the response back to WebKit.568 // sendResponse sends the response back to WebKit.
351 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {569 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
352 if response == nil {570 if response == nil {
@@ -378,14 +596,23 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
378 schemeResp.SetContentType(contentType)596 schemeResp.SetContentType(contentType)
379 schemeResp.SetStatus(uint(response.StatusCode), nil)597 schemeResp.SetStatus(uint(response.StatusCode), nil)
380 598
381- // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().599+ // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset.
382- // We only add CORS headers for our internal API endpoints.600+ // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers.
383- if strings.HasPrefix(req.GetPath(), "/api/") {601+ var headers map[string]string
602+ if !response.SuppressDefaultHeaders {
603+ headers = responseHeadersForPath(req.GetPath(), contentType)
604+ }
605+ for name, value := range response.Headers {
606+ if headers == nil {
607+ headers = map[string]string{}
608+ }
609+ headers[name] = value
610+ }
611+ if len(headers) > 0 {
384 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)612 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
385- hdrs.Append("Access-Control-Allow-Origin", "*")613+ for name, value := range headers {
386- hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")614+ hdrs.Append(name, value)
387- hdrs.Append("Access-Control-Allow-Headers", "Content-Type")615+ }
388- hdrs.Append("Access-Control-Max-Age", "86400")
389 schemeResp.SetHttpHeaders(hdrs)616 schemeResp.SetHttpHeaders(hdrs)
390 }617 }
391 618
M internal/infrastructure/webkit/scheme_handler_test.go +216 -19
@@ -2,7 +2,9 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net/http"
5 "net/url"6 "net/url"
7+ "strings"
6 "testing"8 "testing"
7 9
8 "github.com/bnema/dumber/assets"10 "github.com/bnema/dumber/assets"
@@ -11,8 +13,8 @@ import (
11 )13 )
12 14
13 func TestCrashOriginalURI(t *testing.T) {15 func TestCrashOriginalURI(t *testing.T) {
14- assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))16+ assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
15- assert.Empty(t, crashOriginalURI("dumb://home/crash"))17+ assert.Empty(t, crashOriginalURI("dumb://history/crash"))
16 assert.Empty(t, crashOriginalURI("%%%"))18 assert.Empty(t, crashOriginalURI("%%%"))
17 }19 }
18 20
@@ -37,8 +39,8 @@ func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) {
37 }39 }
38 40
39 func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) {41 func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) {
40- body := buildCrashPageHTML("dumb://home")42+ body := buildCrashPageHTML("dumb://history")
41- assert.Contains(t, body, `data-target="dumb://home"`)43+ assert.Contains(t, body, `data-target="dumb://history"`)
42 }44 }
43 45
44 func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {46 func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
@@ -48,6 +50,41 @@ func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
48 assert.Contains(t, body, "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;")50 assert.Contains(t, body, "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;")
49 }51 }
50 52
53+func TestIsTrustedSystemviewURL(t *testing.T) {
54+ t.Parallel()
55+
56+ for _, raw := range []string{
57+ "dumb://history",
58+ "dumb://favorites",
59+ "dumb:config",
60+ } {
61+ require.True(t, isTrustedSystemviewURL(raw), raw)
62+ }
63+ for _, raw := range []string{
64+ "",
65+ "https://dumber.invalid/history",
66+ "dumb://api/favicon",
67+ "dumb://evil/history",
68+ } {
69+ require.False(t, isTrustedSystemviewURL(raw), raw)
70+ }
71+}
72+
73+func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) {
74+ t.Parallel()
75+
76+ require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"}))
77+ require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""}))
78+}
79+
80+func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) {
81+ t.Parallel()
82+
83+ require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"}))
84+ require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"}))
85+ require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"}))
86+}
87+
51 func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {88 func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
52 handler := NewDumbSchemeHandler(context.Background())89 handler := NewDumbSchemeHandler(context.Background())
53 require.NotNil(t, handler)90 require.NotNil(t, handler)
@@ -59,7 +96,7 @@ func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
59 require.NotNil(t, crashHandler)96 require.NotNil(t, crashHandler)
60 97
61 resp := crashHandler.Handle(&SchemeRequest{98 resp := crashHandler.Handle(&SchemeRequest{
62- URI: "dumb://home/crash?url=https%3A%2F%2Fexample.com",99+ URI: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
63 Path: "/crash",100 Path: "/crash",
64 Method: "GET",101 Method: "GET",
65 Scheme: "dumb",102 Scheme: "dumb",
@@ -81,7 +118,7 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
81 require.NotNil(t, crashHandler)118 require.NotNil(t, crashHandler)
82 119
83 resp := crashHandler.Handle(&SchemeRequest{120 resp := crashHandler.Handle(&SchemeRequest{
84- URI: "dumb://home/crash?url=javascript%3Aalert(1)",121+ URI: "dumb://history/crash?url=javascript%3Aalert(1)",
85 Path: "/crash",122 Path: "/crash",
86 Method: "GET",123 Method: "GET",
87 Scheme: "dumb",124 Scheme: "dumb",
@@ -90,41 +127,201 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
90 assert.Contains(t, string(resp.Data), `data-target=""`)127 assert.Contains(t, string(resp.Data), `data-target=""`)
91 }128 }
92 129
93-func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) {130+func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
94 h := NewDumbSchemeHandler(context.Background())131 h := NewDumbSchemeHandler(context.Background())
95 h.SetAssets(assets.WebUIAssets)132 h.SetAssets(assets.WebUIAssets)
96 133
97- u, err := url.Parse("dumb://webrtc/")134+ u, err := url.Parse("dumb:config")
98 require.NoError(t, err)135 require.NoError(t, err)
99 136
100 resp := h.handleAsset(u)137 resp := h.handleAsset(u)
101 require.NotNil(t, resp)138 require.NotNil(t, resp)
102 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)139 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
103- assert.Contains(t, string(resp.Data), "webrtc.min.js")140+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
104 }141 }
105 142
106-func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {143+func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
107 h := NewDumbSchemeHandler(context.Background())144 h := NewDumbSchemeHandler(context.Background())
108 h.SetAssets(assets.WebUIAssets)145 h.SetAssets(assets.WebUIAssets)
109 146
110- u, err := url.Parse("dumb:webrtc")147+ u, err := url.Parse("dumb://history/systemviews.css")
111 require.NoError(t, err)148 require.NoError(t, err)
112 149
113 resp := h.handleAsset(u)150 resp := h.handleAsset(u)
114 require.NotNil(t, resp)151 require.NotNil(t, resp)
115- assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)152+ // Charset suffixes are acceptable; assert the MIME type contract only.
116- assert.Contains(t, string(resp.Data), "webrtc.min.js")153+ assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
154+ assert.Contains(t, string(resp.Data), ".sv-app")
117 }155 }
118 156
119-func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {157+func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
120 h := NewDumbSchemeHandler(context.Background())158 h := NewDumbSchemeHandler(context.Background())
121 h.SetAssets(assets.WebUIAssets)159 h.SetAssets(assets.WebUIAssets)
122 160
123- u, err := url.Parse("dumb:config")161+ tests := []string{
162+ "dumb://history",
163+ "dumb:history",
164+ "dumb://favorites",
165+ "dumb:favorites",
166+ "dumb://config",
167+ "dumb:config",
168+ "dumb://error",
169+ "dumb:error",
170+ }
171+
172+ for _, raw := range tests {
173+ t.Run(raw, func(t *testing.T) {
174+ u, err := url.Parse(raw)
175+ require.NoError(t, err)
176+
177+ resp := h.handleAsset(u)
178+ require.NotNil(t, resp)
179+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
180+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
181+ })
182+ }
183+}
184+
185+func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
186+ h := NewDumbSchemeHandler(context.Background())
187+ h.SetConfigPayloadBuilders(
188+ func() ([]byte, error) { return []byte(`{"current":true}`), nil },
189+ func() ([]byte, error) { return []byte(`{"default":true}`), nil },
190+ )
191+
192+ h.mu.RLock()
193+ currentHandler := h.handlers["/api/config"]
194+ defaultHandler := h.handlers["/api/config/default"]
195+ h.mu.RUnlock()
196+ require.NotNil(t, currentHandler)
197+ require.NotNil(t, defaultHandler)
198+
199+ currentResp := currentHandler.Handle(&SchemeRequest{
200+ URI: "dumb://config/api/config",
201+ Path: "/api/config",
202+ Method: http.MethodGet,
203+ Scheme: "dumb",
204+ Referer: "dumb://config",
205+ })
206+ require.NotNil(t, currentResp)
207+ assert.Equal(t, http.StatusOK, currentResp.StatusCode)
208+ assert.Equal(t, "application/json", currentResp.ContentType)
209+ assert.Equal(t, []byte(`{"current":true}`), currentResp.Data)
210+
211+ defaultResp := defaultHandler.Handle(&SchemeRequest{
212+ URI: "dumb://config/api/config/default",
213+ Path: "/api/config/default",
214+ Method: http.MethodGet,
215+ Scheme: "dumb",
216+ Referer: "dumb://config",
217+ })
218+ require.NotNil(t, defaultResp)
219+ assert.Equal(t, http.StatusOK, defaultResp.StatusCode)
220+ assert.Equal(t, "application/json", defaultResp.ContentType)
221+ assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
222+}
223+
224+func TestConfigHandlersRejectUntrustedRequests(t *testing.T) {
225+ h := NewDumbSchemeHandler(context.Background())
226+ h.SetConfigPayloadBuilders(
227+ func() ([]byte, error) { return []byte(`{"current":true}`), nil },
228+ func() ([]byte, error) { return []byte(`{"default":true}`), nil },
229+ )
230+
231+ h.mu.RLock()
232+ currentHandler := h.handlers["/api/config"]
233+ defaultHandler := h.handlers["/api/config/default"]
234+ h.mu.RUnlock()
235+
236+ for _, handler := range []PageHandler{currentHandler, defaultHandler} {
237+ resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"})
238+ require.NotNil(t, resp)
239+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
240+ assert.True(t, resp.SuppressDefaultHeaders)
241+ assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin")
242+ }
243+}
244+
245+func TestShouldAddCORSHeaders(t *testing.T) {
246+ t.Parallel()
247+
248+ tests := []struct {
249+ name string
250+ path string
251+ want bool
252+ }{
253+ {name: "private api route", path: "/api/config", want: false},
254+ {name: "wasm asset", path: "/systemviews.wasm", want: true},
255+ {name: "html shell", path: "/", want: false},
256+ {name: "js asset", path: "/wasm_exec.js", want: false},
257+ }
258+
259+ for _, tt := range tests {
260+ t.Run(tt.name, func(t *testing.T) {
261+ t.Parallel()
262+ assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path))
263+ })
264+ }
265+}
266+
267+func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
268+ t.Parallel()
269+
270+ headers := responseHeadersForPath("/systemviews.wasm", "application/wasm")
271+ assert.Equal(t, "application/wasm", headers["Content-Type"])
272+ assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
273+ assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
274+}
275+
276+func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) {
277+ t.Parallel()
278+
279+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
124 require.NoError(t, err)280 require.NoError(t, err)
281+ require.GreaterOrEqual(t, len(data), 4)
282+ assert.Equal(t, "\x00asm", string(data[:4]))
283+}
125 284
126- resp := h.handleAsset(u)285+func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
127- require.NotNil(t, resp)286+ h := NewDumbSchemeHandler(context.Background())
128- assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)287+ h.SetAssets(assets.WebUIAssets)
129- assert.Contains(t, string(resp.Data), "config")288+
289+ for _, raw := range []string{
290+ "dumb://history/../logo.svg",
291+ "dumb://history/nested/../../logo.svg",
292+ } {
293+ t.Run(raw, func(t *testing.T) {
294+ u, err := url.Parse(raw)
295+ require.NoError(t, err)
296+ assert.Nil(t, h.handleAsset(u))
297+ })
298+ }
299+}
300+
301+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
302+ t.Parallel()
303+
304+ fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
305+ require.True(t, ok)
306+ assert.Equal(t, "systemviews/systemviews.css", fullPath)
307+ assert.Equal(t, "systemviews.css", relPath)
308+
309+ invalid := []struct {
310+ name string
311+ assetDir string
312+ relPath string
313+ }{
314+ {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
315+ {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
316+ {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
317+ {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
318+ {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
319+ }
320+ for _, tt := range invalid {
321+ t.Run(tt.name, func(t *testing.T) {
322+ t.Parallel()
323+ _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath)
324+ assert.False(t, ok)
325+ })
326+ }
130 }327 }
M internal/infrastructure/webutil/mime.go +5 -1
@@ -9,7 +9,11 @@ import (
9 // GetMimeType determines the MIME type for a given file path.9 // GetMimeType determines the MIME type for a given file path.
10 // It tries the standard library first, then falls back to common web asset types.10 // It tries the standard library first, then falls back to common web asset types.
11 func GetMimeType(filename string) string {11 func GetMimeType(filename string) string {
12- ext := strings.ToLower(filepath.Ext(filename))12+ lowerName := strings.ToLower(filename)
13+ ext := filepath.Ext(lowerName)
14+ if ext == ".br" {
15+ return GetMimeType(strings.TrimSuffix(lowerName, ext))
16+ }
13 17
14 mt := mime.TypeByExtension(ext)18 mt := mime.TypeByExtension(ext)
15 if mt != "" {19 if mt != "" {
A internal/infrastructure/webutil/mime_test.go +31 -0
@@ -0,0 +1,31 @@
1+package webutil
2+
3+import "testing"
4+
5+func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) {
6+ t.Parallel()
7+
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+ })
30+ }
31+}
M internal/ui/app.go +5 -1
@@ -204,6 +204,10 @@ func New(deps *Dependencies) (*App, error) {
204 glib.IdleAdd(&cb, 0)204 glib.IdleAdd(&cb, 0)
205 })205 })
206 }206 }
207+ if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok {
208+ faviconSetter.SetSystemviewFaviconService(deps.FaviconService)
209+ }
210+
207 // Register message handlers through the engine.211 // Register message handlers through the engine.
208 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{212 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
209 HistoryUC: deps.HistoryUC,213 HistoryUC: deps.HistoryUC,
@@ -795,7 +799,7 @@ func (a *App) initialWindowURL() string {
795 if a.deps != nil && a.deps.InitialURL != "" {799 if a.deps != nil && a.deps.InitialURL != "" {
796 return a.deps.InitialURL800 return a.deps.InitialURL
797 }801 }
798- return "dumb://home"802+ return "dumb://history"
799 }803 }
800 804
801 func (a *App) initLayoutInfrastructure() {805 func (a *App) initLayoutInfrastructure() {
M internal/ui/app_clipboard_test.go +3 -2
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 portmocks "github.com/bnema/dumber/internal/application/port/mocks"9 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -28,7 +29,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
28 Config: &config.Config{},29 Config: &config.Config{},
29 Engine: engine,30 Engine: engine,
30 HandlerDeps: port.HandlerDeps{31 HandlerDeps: port.HandlerDeps{
31- SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },32+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
32 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },33 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
33 },34 },
34 Clipboard: nil,35 Clipboard: nil,
@@ -57,7 +58,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
57 Config: &config.Config{},58 Config: &config.Config{},
58 Engine: engine,59 Engine: engine,
59 HandlerDeps: port.HandlerDeps{60 HandlerDeps: port.HandlerDeps{
60- SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },61+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
61 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },62 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
62 },63 },
63 Clipboard: clipboard,64 Clipboard: clipboard,
M internal/ui/coordinator/content/appearance.go +1 -1
@@ -14,7 +14,7 @@ import (
14 14
15 const (15 const (
16 aboutBlankURI = "about:blank"16 aboutBlankURI = "about:blank"
17- crashPageURI = "dumb://home/crash"17+ crashPageURI = "dumb://history/crash"
18 logURLMaxLen = 8018 logURLMaxLen = 80
19 oauthParentRefreshDebounce = 200 * time.Millisecond19 oauthParentRefreshDebounce = 200 * time.Millisecond
20 20
M internal/ui/coordinator/content/callbacks.go +2 -2
@@ -12,8 +12,8 @@ import (
12 "github.com/bnema/puregotk/v4/gdk"12 "github.com/bnema/puregotk/v4/gdk"
13 )13 )
14 14
15-// internalSchemePath is the path used in dumb:// URIs (replaces webkit.HomePath).15+// internalSchemePath is the host used in dumb:// crash-page URIs.
16-const internalSchemePath = "home"16+const internalSchemePath = "history"
17 17
18 func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {18 func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {
19 switch reason {19 switch reason {
M internal/ui/coordinator/content/content_termination_test.go +4 -4
@@ -15,13 +15,13 @@ func TestShouldRenderCrashPage(t *testing.T) {
15 }15 }
16 16
17 func TestExtractOriginalURIFromCrashPage(t *testing.T) {17 func TestExtractOriginalURIFromCrashPage(t *testing.T) {
18- assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))18+ assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19- assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash"))19+ assert.Empty(t, extractOriginalURIFromCrashPage("dumb://history/crash"))
20 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))20 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))
21 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))21 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))
22 }22 }
23 23
24 func TestBuildCrashPageURI(t *testing.T) {24 func TestBuildCrashPageURI(t *testing.T) {
25- assert.Equal(t, "dumb://home/crash", buildCrashPageURI(""))25+ assert.Equal(t, "dumb://history/crash", buildCrashPageURI(""))
26- assert.Equal(t, "dumb://home/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))26+ assert.Equal(t, "dumb://history/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))
27 }27 }
M internal/ui/coordinator/workspace.go +128 -1
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "net/url"
8+ "strings"
7 9
8 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -171,6 +173,15 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
171 173
172 // Split splits the active pane in the given direction.174 // Split splits the active pane in the given direction.
173 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {175 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
176+ return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(c.newPaneURL))
177+}
178+
179+// SplitWithURL splits the active pane in the given direction and loads initialURL.
180+func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
181+ return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(initialURL))
182+}
183+
184+func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
174 log := logging.FromContext(ctx)185 log := logging.FromContext(ctx)
175 186
176 splitCtx, ok := c.prepareSplit(ctx, direction)187 splitCtx, ok := c.prepareSplit(ctx, direction)
@@ -182,7 +193,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
182 Workspace: splitCtx.ws,193 Workspace: splitCtx.ws,
183 TargetPane: splitCtx.activePane,194 TargetPane: splitCtx.activePane,
184 Direction: direction,195 Direction: direction,
185- InitialURL: domainurl.Normalize(c.newPaneURL),196+ InitialURL: initialURL,
186 })197 })
187 if err != nil {198 if err != nil {
188 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")199 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
@@ -212,6 +223,122 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
212 return nil223 return nil
213 }224 }
214 225
226+// ToggleSystemViewRight focuses an existing system view pane, closes it if it is
227+// already active, or opens it in a right split when no matching pane exists.
228+func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, targetURL string) error {
229+ log := logging.FromContext(ctx)
230+ targetURL = domainurl.Normalize(targetURL)
231+ if targetURL == "" {
232+ return fmt.Errorf("system view URL is required")
233+ }
234+ if c.getActiveWS == nil {
235+ log.Warn().Str("url", targetURL).Msg("active workspace provider not configured")
236+ return fmt.Errorf("system view toggle: active workspace provider not configured")
237+ }
238+
239+ ws, wsView := c.getActiveWS()
240+ if ws == nil {
241+ log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle")
242+ return fmt.Errorf("system view toggle: no active workspace")
243+ }
244+
245+ activePane := ws.ActivePane()
246+ if paneMatchesURL(activePane, targetURL) {
247+ if ws.PaneCount() <= 1 {
248+ log.Debug().Str("url", targetURL).Msg("system view is active in the last pane; leaving it open")
249+ return nil
250+ }
251+ log.Debug().Str("url", targetURL).Msg("system view already active; closing pane")
252+ return c.ClosePane(ctx)
253+ }
254+
255+ if existing := findPaneByURL(ws, targetURL); existing != nil {
256+ log.Debug().Str("url", targetURL).Str("pane_id", string(existing.ID)).Msg("focusing existing system view pane")
257+ c.focusExistingPane(ctx, ws, wsView, existing.ID)
258+ return nil
259+ }
260+
261+ log.Debug().Str("url", targetURL).Msg("opening system view in right split")
262+ return c.SplitWithURL(ctx, usecase.SplitRight, targetURL)
263+}
264+
265+func paneMatchesURL(node *entity.PaneNode, targetURL string) bool {
266+ return node != nil && node.Pane != nil && comparablePaneURL(node.Pane.URI) == comparablePaneURL(targetURL)
267+}
268+
269+func findPaneByURL(ws *entity.Workspace, targetURL string) *entity.Pane {
270+ if ws == nil {
271+ return nil
272+ }
273+ targetURL = comparablePaneURL(targetURL)
274+ for _, pane := range ws.AllPanes() {
275+ if pane != nil && comparablePaneURL(pane.URI) == targetURL {
276+ return pane
277+ }
278+ }
279+ return nil
280+}
281+
282+func comparablePaneURL(raw string) string {
283+ normalized := domainurl.Normalize(raw)
284+ parsed, err := url.Parse(normalized)
285+ if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") {
286+ return normalized
287+ }
288+ page := parsed.Host
289+ if page == "" {
290+ page = parsed.Opaque
291+ }
292+ if page == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
293+ return normalized
294+ }
295+ return "dumb:" + page
296+}
297+
298+func (c *WorkspaceCoordinator) focusExistingPane(
299+ ctx context.Context,
300+ ws *entity.Workspace,
301+ wsView *component.WorkspaceView,
302+ paneID entity.PaneID,
303+) {
304+ oldActivePaneID := ws.ActivePaneID
305+ ws.ActivePaneID = paneID
306+ paneNode := ws.FindPane(paneID)
307+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
308+ setActiveStackIndexForChild(paneNode.Parent, paneNode)
309+ }
310+
311+ if wsView != nil {
312+ wsView.CancelAllPendingHovers()
313+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
314+ if oldActivePaneID != "" && oldActivePaneID != paneID {
315+ wsView.DeactivatePane(oldActivePaneID)
316+ }
317+ if err := wsView.SetActivePaneID(paneID); err != nil {
318+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to focus existing pane in view")
319+ } else {
320+ wsView.FocusPane(paneID)
321+ }
322+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
323+ c.syncStackedViewActive(ctx, wsView, paneNode)
324+ }
325+ }
326+
327+ c.notifyStateChanged()
328+}
329+
330+func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
331+ if parent == nil || child == nil {
332+ return
333+ }
334+ for i, candidate := range parent.Children {
335+ if candidate == child {
336+ parent.ActiveStackIndex = i
337+ return
338+ }
339+ }
340+}
341+
215 func (c *WorkspaceCoordinator) prepareSplit(342 func (c *WorkspaceCoordinator) prepareSplit(
216 ctx context.Context,343 ctx context.Context,
217 direction usecase.SplitDirection,344 direction usecase.SplitDirection,
A internal/ui/coordinator/workspace_systemview_test.go +126 -0
@@ -0,0 +1,126 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestWorkspaceCoordinator_ToggleSystemViewRightSplitsWithTargetURL(t *testing.T) {
15+ ctx := context.Background()
16+ ids := []string{"pane-2", "split-1"}
17+ idx := 0
18+ uc := usecase.NewManagePanesUseCase(func() string {
19+ id := ids[idx]
20+ idx++
21+ return id
22+ })
23+
24+ initialPane := entity.NewPane("pane-1")
25+ initialPane.URI = "https://example.com"
26+ ws := entity.NewWorkspace("ws-1", initialPane)
27+
28+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
29+ PanesUC: uc,
30+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
31+ return ws, nil
32+ },
33+ })
34+
35+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
36+ require.NoError(t, err)
37+
38+ require.Equal(t, 2, ws.PaneCount())
39+ active := ws.ActivePane()
40+ require.NotNil(t, active)
41+ require.NotNil(t, active.Pane)
42+ assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID)
43+ assert.Equal(t, "dumb://history", active.Pane.URI)
44+}
45+
46+func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testing.T) {
47+ ctx := context.Background()
48+ left := testLeafNode("pane-1")
49+ left.Pane.URI = "https://example.com"
50+ right := testLeafNode("pane-2")
51+ right.Pane.URI = "dumb://history"
52+ root := testSplitNode("split-1", left, right)
53+ ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID}
54+
55+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
56+ PanesUC: usecase.NewManagePanesUseCase(func() string {
57+ t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane")
58+ return "unused"
59+ }),
60+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
61+ return ws, nil
62+ },
63+ })
64+
65+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
66+ require.NoError(t, err)
67+
68+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
69+ assert.Equal(t, 2, ws.PaneCount())
70+}
71+
72+func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T) {
73+ ctx := context.Background()
74+ uc := usecase.NewManagePanesUseCase(func() string { return "unused" })
75+ left := testLeafNode("pane-1")
76+ left.Pane.URI = "https://example.com"
77+ right := testLeafNode("pane-2")
78+ right.Pane.URI = "dumb://history"
79+ root := testSplitNode("split-1", left, right)
80+ ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: right.Pane.ID}
81+
82+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
83+ PanesUC: uc,
84+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
85+ return ws, nil
86+ },
87+ })
88+
89+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
90+ require.NoError(t, err)
91+
92+ assert.Equal(t, 1, ws.PaneCount())
93+ assert.Equal(t, entity.PaneID("pane-1"), ws.ActivePaneID)
94+ active := ws.ActivePane()
95+ require.NotNil(t, active)
96+ assert.Equal(t, "https://example.com", active.Pane.URI)
97+}
98+
99+func TestWorkspaceCoordinator_ToggleSystemViewRightUpdatesStackIndexForExistingPane(t *testing.T) {
100+ ctx := context.Background()
101+ first := testLeafNode("pane-1")
102+ first.Pane.URI = "https://example.com"
103+ second := testLeafNode("pane-2")
104+ second.Pane.URI = "dumb://history"
105+ stack := &entity.PaneNode{
106+ ID: "stack-1",
107+ IsStacked: true,
108+ ActiveStackIndex: 0,
109+ Children: []*entity.PaneNode{first, second},
110+ }
111+ first.Parent = stack
112+ second.Parent = stack
113+ ws := &entity.Workspace{ID: "ws-1", Root: stack, ActivePaneID: first.Pane.ID}
114+
115+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
116+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
117+ return ws, nil
118+ },
119+ })
120+
121+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
122+ require.NoError(t, err)
123+
124+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
125+ assert.Equal(t, 1, stack.ActiveStackIndex)
126+}
M internal/ui/dispatcher/keyboard.go +15 -0
@@ -14,6 +14,12 @@ import (
14 "github.com/bnema/puregotk/v4/glib"14 "github.com/bnema/puregotk/v4/glib"
15 )15 )
16 16
17+const (
18+ historySystemViewURL = "dumb://history"
19+ favoritesSystemViewURL = "dumb://favorites"
20+ configSystemViewURL = "dumb://config"
21+)
22+
17 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.23 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
18 type KeyboardDispatcher struct {24 type KeyboardDispatcher struct {
19 tabCoord *coordinator.TabCoordinator25 tabCoord *coordinator.TabCoordinator
@@ -225,6 +231,15 @@ func (d *KeyboardDispatcher) initActionHandlers() {
225 }231 }
226 return d.logNoop(ctx, "toggle floating pane action (no handler)")232 return d.logNoop(ctx, "toggle floating pane action (no handler)")
227 },233 },
234+ input.ActionToggleHistorySystemView: func(ctx context.Context) error {
235+ return d.wsCoord.ToggleSystemViewRight(ctx, historySystemViewURL)
236+ },
237+ input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
238+ return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
239+ },
240+ input.ActionToggleConfigSystemView: func(ctx context.Context) error {
241+ return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
242+ },
228 input.ActionToggleFullscreen: func(ctx context.Context) error {243 input.ActionToggleFullscreen: func(ctx context.Context) error {
229 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")244 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
230 },245 },
M internal/ui/dispatcher/keyboard_test.go +34 -0
@@ -7,6 +7,7 @@ import (
7 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/ui/component"
10 "github.com/bnema/dumber/internal/ui/coordinator"11 "github.com/bnema/dumber/internal/ui/coordinator"
11 "github.com/bnema/dumber/internal/ui/input"12 "github.com/bnema/dumber/internal/ui/input"
12 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
@@ -114,6 +115,39 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout
114 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)115 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
115 }116 }
116 117
118+func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {
119+ ctx := context.Background()
120+ ids := []string{"pane-2", "split-1"}
121+ idx := 0
122+ panesUC := usecase.NewManagePanesUseCase(func() string {
123+ id := ids[idx]
124+ idx++
125+ return id
126+ })
127+
128+ initialPane := entity.NewPane("pane-1")
129+ initialPane.URI = "https://example.com"
130+ ws := entity.NewWorkspace("ws-1", initialPane)
131+ wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
132+ PanesUC: panesUC,
133+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
134+ return ws, nil
135+ },
136+ })
137+
138+ d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
139+
140+ err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
141+ require.NoError(t, err)
142+
143+ require.Equal(t, 2, ws.PaneCount())
144+ active := ws.ActivePane()
145+ require.NotNil(t, active)
146+ require.NotNil(t, active.Pane)
147+ assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID)
148+ assert.Equal(t, "dumb://history", active.Pane.URI)
149+}
150+
117 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {151 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
118 ctx := context.Background()152 ctx := context.Background()
119 activePaneID := entity.PaneID("pane-1")153 activePaneID := entity.PaneID("pane-1")
M internal/ui/input/global_shortcuts.go +24 -17
@@ -104,15 +104,7 @@ func NewGlobalShortcutHandler(
104 Msg("registered global shortcut")104 Msg("registered global shortcut")
105 105
106 if workspace != nil {106 if workspace != nil {
107- // Map action names to action constants for global shortcuts107+ actionMap := globalShortcutActionMap()
108- actionMap := map[string]Action{
109- "toggle_floating_pane": ActionToggleFloatingPane,
110- "toggle-floating-pane": ActionToggleFloatingPane,
111- "consume_or_expel_left": ActionConsumeOrExpelLeft,
112- "consume_or_expel_right": ActionConsumeOrExpelRight,
113- "consume_or_expel_up": ActionConsumeOrExpelUp,
114- "consume_or_expel_down": ActionConsumeOrExpelDown,
115- }
116 108
117 for actionName, actionBinding := range workspace.Shortcuts.Actions {109 for actionName, actionBinding := range workspace.Shortcuts.Actions {
118 action, ok := actionMap[actionName]110 action, ok := actionMap[actionName]
@@ -247,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
247 return true239 return true
248 }240 }
249 241
242+// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
243+func globalShortcutActionMap() map[string]Action {
244+ return map[string]Action{
245+ "toggle_floating_pane": ActionToggleFloatingPane,
246+ "toggle-floating-pane": ActionToggleFloatingPane,
247+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
248+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
249+ "consume_or_expel_right": ActionConsumeOrExpelRight,
250+ "consume-or-expel-right": ActionConsumeOrExpelRight,
251+ "consume_or_expel_up": ActionConsumeOrExpelUp,
252+ "consume-or-expel-up": ActionConsumeOrExpelUp,
253+ "consume_or_expel_down": ActionConsumeOrExpelDown,
254+ "consume-or-expel-down": ActionConsumeOrExpelDown,
255+ "toggle_history_systemview": ActionToggleHistorySystemView,
256+ "toggle-history-systemview": ActionToggleHistorySystemView,
257+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
258+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
259+ "toggle_config_systemview": ActionToggleConfigSystemView,
260+ "toggle-config-systemview": ActionToggleConfigSystemView,
261+ }
262+}
263+
250 // ReloadShortcuts rebuilds global shortcuts from a new config.264 // ReloadShortcuts rebuilds global shortcuts from a new config.
251 // It replaces the GTK shortcut controller to ensure stale shortcuts265 // It replaces the GTK shortcut controller to ensure stale shortcuts
252 // are removed.266 // are removed.
@@ -285,14 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
285 299
286 // Re-register config-driven shortcuts300 // Re-register config-driven shortcuts
287 if workspace != nil {301 if workspace != nil {
288- actionMap := map[string]Action{302+ actionMap := globalShortcutActionMap()
289- "toggle_floating_pane": ActionToggleFloatingPane,
290- "toggle-floating-pane": ActionToggleFloatingPane,
291- "consume_or_expel_left": ActionConsumeOrExpelLeft,
292- "consume_or_expel_right": ActionConsumeOrExpelRight,
293- "consume_or_expel_up": ActionConsumeOrExpelUp,
294- "consume_or_expel_down": ActionConsumeOrExpelDown,
295- }
296 303
297 for actionName, actionBinding := range workspace.Shortcuts.Actions {304 for actionName, actionBinding := range workspace.Shortcuts.Actions {
298 action, ok := actionMap[actionName]305 action, ok := actionMap[actionName]
M internal/ui/input/shortcuts.go +38 -32
@@ -179,14 +179,17 @@ const (
179 ActionZoomReset Action = "zoom_reset"179 ActionZoomReset Action = "zoom_reset"
180 180
181 // UI181 // UI
182- ActionOpenOmnibox Action = "open_omnibox"182+ ActionOpenOmnibox Action = "open_omnibox"
183- ActionOpenFind Action = "open_find"183+ ActionOpenFind Action = "open_find"
184- ActionFindNext Action = "find_next"184+ ActionFindNext Action = "find_next"
185- ActionFindPrev Action = "find_prev"185+ ActionFindPrev Action = "find_prev"
186- ActionCloseFind Action = "close_find"186+ ActionCloseFind Action = "close_find"
187- ActionOpenDevTools Action = "open_devtools"187+ ActionOpenDevTools Action = "open_devtools"
188- ActionToggleFullscreen Action = "toggle_fullscreen"188+ ActionToggleFullscreen Action = "toggle_fullscreen"
189- ActionToggleFloatingPane Action = "toggle_floating_pane"189+ ActionToggleFloatingPane Action = "toggle_floating_pane"
190+ ActionToggleHistorySystemView Action = "toggle_history_systemview"
191+ ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
192+ ActionToggleConfigSystemView Action = "toggle_config_systemview"
190 193
191 // Clipboard194 // Clipboard
192 ActionCopyURL Action = "copy_url"195 ActionCopyURL Action = "copy_url"
@@ -359,29 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
359 // r = rename tab362 // r = rename tab
360 // This follows Zellij-style modal keyboard interface.363 // This follows Zellij-style modal keyboard interface.
361 //364 //
362- // However, these standard browser shortcuts ARE global:365+ // However, these standard browser shortcuts ARE global.
363- // Map action names to action constants
364- actionMap := map[string]Action{
365- "toggle_floating_pane": ActionToggleFloatingPane,
366- "toggle-floating-pane": ActionToggleFloatingPane,
367- "close_pane": ActionClosePane,
368- "close-pane": ActionClosePane,
369- "next_tab": ActionNextTab,
370- "next-tab": ActionNextTab,
371- "previous_tab": ActionPreviousTab,
372- "previous-tab": ActionPreviousTab,
373- "consume_or_expel_left": ActionConsumeOrExpelLeft,
374- "consume-or-expel-left": ActionConsumeOrExpelLeft,
375- "consume_or_expel_right": ActionConsumeOrExpelRight,
376- "consume-or-expel-right": ActionConsumeOrExpelRight,
377- "consume_or_expel_up": ActionConsumeOrExpelUp,
378- "consume-or-expel-up": ActionConsumeOrExpelUp,
379- "consume_or_expel_down": ActionConsumeOrExpelDown,
380- "consume-or-expel-down": ActionConsumeOrExpelDown,
381- }
382-
383 for actionName, actionBinding := range cfg.Shortcuts.Actions {366 for actionName, actionBinding := range cfg.Shortcuts.Actions {
384- action, ok := actionMap[actionName]367+ action, ok := configActionToAction[actionName]
385 if !ok {368 if !ok {
386 continue369 continue
387 }370 }
@@ -580,29 +563,52 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
580 }563 }
581 564
582 var configActionToAction = map[string]Action{565 var configActionToAction = map[string]Action{
583- "toggle_floating_pane": ActionToggleFloatingPane,566+ "toggle_floating_pane": ActionToggleFloatingPane,
584- "toggle-floating-pane": ActionToggleFloatingPane,567+ "toggle-floating-pane": ActionToggleFloatingPane,
568+ "toggle_history_systemview": ActionToggleHistorySystemView,
569+ "toggle-history-systemview": ActionToggleHistorySystemView,
570+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
571+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
572+ "toggle_config_systemview": ActionToggleConfigSystemView,
573+ "toggle-config-systemview": ActionToggleConfigSystemView,
585 574
586 // Tab actions575 // Tab actions
576+ "new_tab": ActionNewTab,
587 "new-tab": ActionNewTab,577 "new-tab": ActionNewTab,
578+ "close_tab": ActionCloseTab,
588 "close-tab": ActionCloseTab,579 "close-tab": ActionCloseTab,
580+ "next_tab": ActionNextTab,
589 "next-tab": ActionNextTab,581 "next-tab": ActionNextTab,
582+ "previous_tab": ActionPreviousTab,
590 "previous-tab": ActionPreviousTab,583 "previous-tab": ActionPreviousTab,
584+ "rename_tab": ActionRenameTab,
591 "rename-tab": ActionRenameTab,585 "rename-tab": ActionRenameTab,
592 586
593 // Pane actions587 // Pane actions
588+ "split_right": ActionSplitRight,
594 "split-right": ActionSplitRight,589 "split-right": ActionSplitRight,
590+ "split_left": ActionSplitLeft,
595 "split-left": ActionSplitLeft,591 "split-left": ActionSplitLeft,
592+ "split_up": ActionSplitUp,
596 "split-up": ActionSplitUp,593 "split-up": ActionSplitUp,
594+ "split_down": ActionSplitDown,
597 "split-down": ActionSplitDown,595 "split-down": ActionSplitDown,
596+ "close_pane": ActionClosePane,
598 "close-pane": ActionClosePane,597 "close-pane": ActionClosePane,
598+ "stack_pane": ActionStackPane,
599 "stack-pane": ActionStackPane,599 "stack-pane": ActionStackPane,
600+ "move_pane_to_tab": ActionMovePaneToTab,
600 "move-pane-to-tab": ActionMovePaneToTab,601 "move-pane-to-tab": ActionMovePaneToTab,
602+ "move_pane_to_next_tab": ActionMovePaneToNextTab,
601 "move-pane-to-next-tab": ActionMovePaneToNextTab,603 "move-pane-to-next-tab": ActionMovePaneToNextTab,
602 604
605+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
603 "consume-or-expel-left": ActionConsumeOrExpelLeft,606 "consume-or-expel-left": ActionConsumeOrExpelLeft,
607+ "consume_or_expel_right": ActionConsumeOrExpelRight,
604 "consume-or-expel-right": ActionConsumeOrExpelRight,608 "consume-or-expel-right": ActionConsumeOrExpelRight,
609+ "consume_or_expel_up": ActionConsumeOrExpelUp,
605 "consume-or-expel-up": ActionConsumeOrExpelUp,610 "consume-or-expel-up": ActionConsumeOrExpelUp,
611+ "consume_or_expel_down": ActionConsumeOrExpelDown,
606 "consume-or-expel-down": ActionConsumeOrExpelDown,612 "consume-or-expel-down": ActionConsumeOrExpelDown,
607 613
608 // Focus navigation614 // Focus navigation
M internal/ui/input/shortcuts_test.go +59 -0
@@ -383,9 +383,68 @@ func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
383 }383 }
384 }384 }
385 385
386+func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
387+ tests := []struct {
388+ name string
389+ want Action
390+ }{
391+ {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
392+ {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
393+ {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
394+ }
395+
396+ for _, tt := range tests {
397+ t.Run(tt.name, func(t *testing.T) {
398+ if got := mapConfigAction(tt.name); got != tt.want {
399+ t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
400+ }
401+ })
402+ }
403+}
404+
405+func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
406+ tests := []struct {
407+ name string
408+ want Action
409+ }{
410+ {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
411+ {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
412+ {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
413+ }
414+
415+ for _, tt := range tests {
416+ t.Run(tt.name, func(t *testing.T) {
417+ if got := mapConfigAction(tt.name); got != tt.want {
418+ t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
419+ }
420+ })
421+ }
422+}
423+
386 func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {424 func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
387 action := mapConfigAction("toggle-floating-pane")425 action := mapConfigAction("toggle-floating-pane")
388 if action != ActionToggleFloatingPane {426 if action != ActionToggleFloatingPane {
389 t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)427 t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
390 }428 }
391 }429 }
430+
431+func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
432+ actions := globalShortcutActionMap()
433+ tests := []struct {
434+ name string
435+ want Action
436+ }{
437+ {name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft},
438+ {name: "consume-or-expel-right", want: ActionConsumeOrExpelRight},
439+ {name: "consume-or-expel-up", want: ActionConsumeOrExpelUp},
440+ {name: "consume-or-expel-down", want: ActionConsumeOrExpelDown},
441+ }
442+
443+ for _, tt := range tests {
444+ t.Run(tt.name, func(t *testing.T) {
445+ if got := actions[tt.name]; got != tt.want {
446+ t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want)
447+ }
448+ })
449+ }
450+}
M internal/ui/standalone_runtime_test.go +1 -1
@@ -74,7 +74,7 @@ func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) {
74 }74 }
75 75
76 func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) {76 func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) {
77- for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {77+ for _, rawURL := range []string{"dumb://history", "dumb://error", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
78 if urlutil.IsExternalScheme(rawURL) {78 if urlutil.IsExternalScheme(rawURL) {
79 t.Fatalf("expected %q to stay internal", rawURL)79 t.Fatalf("expected %q to stay internal", rawURL)
80 }80 }
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+}
A internal/ui/systemviews/app.go +832 -0
@@ -0,0 +1,832 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+type Dependencies struct {
17+ DOM DOM
18+ Favorites port.SystemviewFavoritesService
19+ History port.SystemviewHistoryService
20+ Config port.SystemviewConfigService
21+ LocationURI string
22+}
23+
24+type App struct {
25+ // mu protects rendered route state; actionMu protects worker channels.
26+ // The two locks must not be held at the same time.
27+ mu sync.Mutex
28+ actionMu sync.Mutex
29+ deps Dependencies
30+ currentRoute Route
31+ shellTheme shellTheme
32+ historyEntries []*entity.HistoryEntry
33+ historyStats *entity.HistoryStats
34+ historyAnalytics *entity.HistoryAnalytics
35+ historyDomainStats []*entity.DomainStat
36+ historyQuery string
37+ historyDomainFilter string
38+ historyOffset int
39+ historyWindowBefore time.Time
40+ historyWindowAfter time.Time
41+ historyHasMore bool
42+ historyNotice string
43+ historyError string
44+ favorites []*entity.Favorite
45+ folders []*entity.Folder
46+ tags []*entity.Tag
47+ favoriteFolderFilter *entity.FolderID
48+ favoriteTagFilter *entity.TagID
49+ favoritesNotice string
50+ favoritesError string
51+ config *dto.SystemviewConfigPayload
52+ keybindings port.KeybindingsConfig
53+ configNotice string
54+ configError string
55+ renderedHTML string
56+ renderGeneration uint64
57+ closed bool
58+ actionQueue chan DOMAction
59+ actionErrorQueue chan error
60+ actionCtx context.Context
61+ actionCancel context.CancelFunc
62+ actionClosed bool
63+ actionWG sync.WaitGroup
64+}
65+
66+const (
67+ historyTimelineLimit = 0
68+ historySearchLimit = 100
69+)
70+
71+func (a *App) lockAction() {
72+ // Lock order: actionMu is never acquired while App.mu is held.
73+ a.actionMu.Lock()
74+}
75+
76+func (a *App) unlockAction() {
77+ a.actionMu.Unlock()
78+}
79+
80+func (a *App) lockState() {
81+ // Lock order: App.mu is never held while acquiring actionMu.
82+ a.mu.Lock()
83+}
84+
85+func (a *App) unlockState() {
86+ a.mu.Unlock()
87+}
88+
89+func NewApp(deps Dependencies) *App {
90+ return &App{deps: deps, currentRoute: RouteUnknown}
91+}
92+
93+func (a *App) Run() error {
94+ return a.RunWithContext(context.Background())
95+}
96+
97+func (a *App) RunWithContext(ctx context.Context) error {
98+ if a == nil {
99+ return errors.New("app is nil")
100+ }
101+ if ctx == nil {
102+ return errors.New("context is nil")
103+ }
104+
105+ a.currentRoute = ParseRoute(a.deps.LocationURI)
106+ if a.deps.DOM == nil {
107+ return errors.New("DOM not configured")
108+ }
109+ asyncInitial := a.shouldLoadInitialRouteAsync()
110+ if asyncInitial {
111+ a.LoadInitialShell(ctx)
112+ } else if err := a.LoadInitial(ctx); err != nil {
113+ return err
114+ }
115+
116+ if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
117+ return err
118+ }
119+ if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
120+ if err := a.bindDOMActions(ctx, binder); err != nil {
121+ return err
122+ }
123+ }
124+ if asyncInitial {
125+ a.refreshCurrentRouteAsync(ctx)
126+ }
127+ return nil
128+}
129+
130+func (a *App) shouldLoadInitialRouteAsync() bool {
131+ return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
132+}
133+
134+func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
135+ if a == nil {
136+ return
137+ }
138+ a.lockState()
139+ snapshot := a.currentHistoryRouteSnapshotLocked()
140+ generation := a.renderGeneration
141+ closed := a.closed
142+ a.unlockState()
143+ if closed || snapshot.route != RouteHistory {
144+ return
145+ }
146+
147+ a.lockAction()
148+ asyncCtx := a.actionCtx
149+ if asyncCtx == nil {
150+ asyncCtx = ctx
151+ }
152+ ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
153+ if ready {
154+ a.actionWG.Add(1)
155+ }
156+ a.unlockAction()
157+ if !ready {
158+ return
159+ }
160+
161+ go func() {
162+ defer a.actionWG.Done()
163+ if asyncCtx.Err() != nil {
164+ return
165+ }
166+ result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
167+
168+ a.lockState()
169+ if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
170+ a.unlockState()
171+ return
172+ }
173+ if err != nil {
174+ a.renderRouteError(err)
175+ } else {
176+ a.commitHistoryRouteResultLocked(result)
177+ }
178+ a.renderGeneration++
179+ mountGeneration := a.renderGeneration
180+ html := a.renderedHTML
181+ a.unlockState()
182+
183+ if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
184+ logActionMountError(asyncCtx, err, nil)
185+ }
186+ }()
187+}
188+
189+func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
190+ var (
191+ created bool
192+ workerCtx context.Context
193+ cancel context.CancelFunc
194+ queue chan DOMAction
195+ errorQueue chan error
196+ )
197+
198+ a.lockAction()
199+ if a.actionClosed {
200+ a.unlockAction()
201+ return errors.New("systemview app is closed")
202+ }
203+ if a.actionQueue == nil {
204+ workerCtx, cancel = context.WithCancel(ctx)
205+ queue = make(chan DOMAction, 64)
206+ errorQueue = make(chan error, 1)
207+ a.actionQueue = queue
208+ a.actionErrorQueue = errorQueue
209+ a.actionCtx = workerCtx
210+ a.actionCancel = cancel
211+ created = true
212+ }
213+ a.unlockAction()
214+
215+ if err := binder.BindActions(func(action DOMAction) {
216+ if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
217+ a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
218+ }
219+ }); err != nil {
220+ if created {
221+ a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
222+ }
223+ return err
224+ }
225+
226+ if created && !a.startActionWorkers(workerCtx, queue, errorQueue) {
227+ a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
228+ a.releaseDOMBindings()
229+ return errors.New("systemview action worker unavailable")
230+ }
231+ return nil
232+}
233+
234+func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool {
235+ if a == nil || ctx == nil || queue == nil || errorQueue == nil {
236+ return false
237+ }
238+ a.lockAction()
239+ ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil
240+ if ready {
241+ a.actionWG.Add(2)
242+ }
243+ a.unlockAction()
244+ if !ready {
245+ return false
246+ }
247+ go func() {
248+ defer a.actionWG.Done()
249+ a.runActionWorker(ctx, queue)
250+ }()
251+ go func() {
252+ defer a.actionWG.Done()
253+ a.runActionErrorWorker(ctx, errorQueue)
254+ }()
255+ return true
256+}
257+
258+func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) {
259+ if a == nil {
260+ return
261+ }
262+ a.lockAction()
263+ if a.actionCtx == ctx && a.actionQueue == queue {
264+ a.actionCancel = nil
265+ a.actionCtx = nil
266+ a.actionQueue = nil
267+ a.actionErrorQueue = nil
268+ if cancel != nil {
269+ cancel()
270+ }
271+ close(queue)
272+ close(errorQueue)
273+ }
274+ a.unlockAction()
275+}
276+
277+func (a *App) actionWorkerActive() bool {
278+ if a == nil {
279+ return false
280+ }
281+ a.lockAction()
282+ closed := a.actionClosed
283+ queue := a.actionQueue
284+ ctx := a.actionCtx
285+ a.unlockAction()
286+ return !closed && queue != nil && ctx != nil && ctx.Err() == nil
287+}
288+
289+func (a *App) enqueueDOMAction(action DOMAction) bool {
290+ if a == nil {
291+ return false
292+ }
293+ a.lockAction()
294+ defer a.unlockAction()
295+ queue := a.actionQueue
296+ ctx := a.actionCtx
297+ if a.actionClosed || queue == nil || ctx == nil {
298+ return false
299+ }
300+ select {
301+ case <-ctx.Done():
302+ return false
303+ case queue <- action:
304+ return true
305+ default:
306+ return false
307+ }
308+}
309+
310+func (a *App) enqueueActionError(err error) {
311+ if a == nil || err == nil {
312+ return
313+ }
314+ a.lockAction()
315+ defer a.unlockAction()
316+ queue := a.actionErrorQueue
317+ ctx := a.actionCtx
318+ if a.actionClosed || queue == nil || ctx == nil {
319+ return
320+ }
321+ select {
322+ case <-ctx.Done():
323+ return
324+ case queue <- err:
325+ default:
326+ }
327+}
328+
329+func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
330+ for {
331+ select {
332+ case <-ctx.Done():
333+ return
334+ case action, ok := <-queue:
335+ if !ok || ctx.Err() != nil {
336+ return
337+ }
338+ if err := a.HandleDOMAction(ctx, action); err != nil {
339+ a.enqueueActionError(err)
340+ }
341+ }
342+ }
343+}
344+
345+func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) {
346+ for {
347+ select {
348+ case <-ctx.Done():
349+ return
350+ case err, ok := <-queue:
351+ if !ok || ctx.Err() != nil {
352+ return
353+ }
354+ a.surfaceActionError(ctx, err)
355+ }
356+ }
357+}
358+
359+func (a *App) surfaceActionError(ctx context.Context, err error) {
360+ if a == nil || err == nil {
361+ return
362+ }
363+ a.lockState()
364+ a.renderRouteError(err)
365+ a.renderGeneration++
366+ generation := a.renderGeneration
367+ html := a.renderedHTML
368+ a.unlockState()
369+ if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
370+ logActionMountError(ctx, mountErr, err)
371+ }
372+}
373+
374+func (a *App) Close() {
375+ if a == nil {
376+ return
377+ }
378+ a.lockState()
379+ a.closed = true
380+ a.renderGeneration++
381+ a.unlockState()
382+
383+ a.lockAction()
384+ cancel := a.actionCancel
385+ actionQueue := a.actionQueue
386+ actionErrorQueue := a.actionErrorQueue
387+ a.actionClosed = true
388+ a.actionCancel = nil
389+ a.actionCtx = nil
390+ a.actionQueue = nil
391+ a.actionErrorQueue = nil
392+ a.unlockAction()
393+
394+ if cancel != nil {
395+ cancel()
396+ }
397+ a.actionWG.Wait()
398+ if actionQueue != nil {
399+ close(actionQueue)
400+ }
401+ if actionErrorQueue != nil {
402+ close(actionErrorQueue)
403+ }
404+ a.releaseDOMBindings()
405+}
406+
407+func (a *App) releaseDOMBindings() {
408+ if a == nil {
409+ return
410+ }
411+ if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
412+ releaser.Release()
413+ }
414+}
415+
416+func (a *App) LoadInitialShell(ctx context.Context) {
417+ if a == nil {
418+ return
419+ }
420+ a.loadShellTheme(ctx)
421+ if a.currentRoute == "" || a.currentRoute == RouteUnknown {
422+ a.currentRoute = ParseRoute(a.deps.LocationURI)
423+ }
424+ switch a.currentRoute {
425+ case RouteHistory:
426+ a.loadHistoryLoadingRoute()
427+ default:
428+ a.resetRouteState()
429+ a.renderedHTML = renderAppFrame(renderedPage{
430+ route: a.currentRoute,
431+ title: routeDocumentTitle(a.currentRoute),
432+ subtitle: routeSubtitle(a.currentRoute),
433+ body: placeholderHTML(a.currentRoute),
434+ }, a.shellTheme)
435+ }
436+}
437+
438+func (a *App) LoadInitial(ctx context.Context) error {
439+ if a == nil {
440+ return errors.New("app is nil")
441+ }
442+ a.loadShellTheme(ctx)
443+ if a.currentRoute == "" || a.currentRoute == RouteUnknown {
444+ a.currentRoute = ParseRoute(a.deps.LocationURI)
445+ }
446+
447+ if err := a.loadCurrentRoute(ctx); err != nil {
448+ a.renderRouteError(err)
449+ }
450+ return nil
451+}
452+
453+func (a *App) loadCurrentRoute(ctx context.Context) error {
454+ switch a.currentRoute {
455+ case RouteHistory:
456+ return a.loadHistoryRoute(ctx)
457+ case RouteFavorites:
458+ return a.loadFavoritesRoute(ctx)
459+ case RouteConfig:
460+ return a.loadConfigRoute(ctx)
461+ default:
462+ a.resetRouteState()
463+ a.renderedHTML = renderAppFrame(renderedPage{
464+ route: a.currentRoute,
465+ title: routeDocumentTitle(a.currentRoute),
466+ subtitle: string(a.currentRoute),
467+ body: placeholderHTML(a.currentRoute),
468+ }, a.shellTheme)
469+ return nil
470+ }
471+}
472+
473+func (a *App) renderRouteError(err error) {
474+ a.resetRouteState()
475+ message := "unknown error"
476+ if err != nil {
477+ message = err.Error()
478+ }
479+ a.renderedHTML = renderAppFrame(renderedPage{
480+ route: a.currentRoute,
481+ title: routeDocumentTitle(a.currentRoute) + " — Error",
482+ subtitle: routeSubtitle(a.currentRoute),
483+ body: errorStateHTML(message),
484+ }, a.shellTheme)
485+}
486+
487+func routeDocumentTitle(route Route) string {
488+ switch route {
489+ case RouteHistory:
490+ return "History"
491+ case RouteFavorites:
492+ return "Favorites"
493+ case RouteConfig:
494+ return "Config"
495+ default:
496+ return "Dumber System View"
497+ }
498+}
499+
500+func routeSubtitle(route Route) string {
501+ switch route {
502+ case RouteHistory:
503+ return "Recent visits"
504+ case RouteFavorites:
505+ return "Saved bookmarks"
506+ case RouteConfig:
507+ return "Browser settings"
508+ default:
509+ return string(route)
510+ }
511+}
512+
513+func (a *App) loadHistoryLoadingRoute() {
514+ a.favorites = nil
515+ a.folders = nil
516+ a.tags = nil
517+ a.historyEntries = nil
518+ a.historyStats = nil
519+ a.historyAnalytics = nil
520+ a.historyDomainStats = nil
521+ a.historyNotice = ""
522+ a.historyError = ""
523+ data := historyRenderData{
524+ Query: a.historyQuery,
525+ DomainFilter: a.historyDomainFilter,
526+ Offset: a.historyOffset,
527+ Limit: historyTimelineLimit,
528+ WindowBefore: a.historyWindowBefore,
529+ WindowAfter: a.historyWindowAfter,
530+ HasMore: a.historyHasMore,
531+ Notice: a.historyNotice,
532+ Error: a.historyError,
533+ Loading: true,
534+ }
535+ title := historyDocumentTitle(data)
536+ a.renderedHTML = renderAppFrame(renderedPage{
537+ route: RouteHistory,
538+ title: title,
539+ subtitle: "History",
540+ subtitleDetail: historyTitleDetail(data),
541+ body: historyHTML(data),
542+ }, a.shellTheme)
543+}
544+
545+func (a *App) loadHistoryRoute(ctx context.Context) error {
546+ if a.deps.History == nil {
547+ a.resetRouteState()
548+ a.renderedHTML = renderAppFrame(renderedPage{
549+ route: a.currentRoute,
550+ title: routeDocumentTitle(RouteHistory),
551+ subtitle: routeDocumentTitle(RouteHistory),
552+ body: placeholderHTML(a.currentRoute),
553+ }, a.shellTheme)
554+ return nil
555+ }
556+
557+ result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
558+ if err != nil {
559+ return err
560+ }
561+ a.commitHistoryRouteResultLocked(result)
562+ return nil
563+}
564+
565+type historyRouteSnapshot struct {
566+ route Route
567+ history port.SystemviewHistoryService
568+ shellTheme shellTheme
569+ query string
570+ domainFilter string
571+ offset int
572+ windowBefore time.Time
573+ windowAfter time.Time
574+ notice string
575+ error string
576+}
577+
578+type historyRouteResult struct {
579+ entries []*entity.HistoryEntry
580+ stats *entity.HistoryStats
581+ domains []*entity.DomainStat
582+ windowBefore time.Time
583+ windowAfter time.Time
584+ hasMore bool
585+ html string
586+}
587+
588+func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
589+ return historyRouteSnapshot{
590+ route: a.currentRoute,
591+ history: a.deps.History,
592+ shellTheme: a.shellTheme,
593+ query: a.historyQuery,
594+ domainFilter: a.historyDomainFilter,
595+ offset: a.historyOffset,
596+ windowBefore: a.historyWindowBefore,
597+ windowAfter: a.historyWindowAfter,
598+ notice: a.historyNotice,
599+ error: a.historyError,
600+ }
601+}
602+
603+func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
604+ if snapshot.history == nil {
605+ return historyRouteResult{
606+ html: renderAppFrame(renderedPage{
607+ route: snapshot.route,
608+ title: routeDocumentTitle(RouteHistory),
609+ subtitle: routeDocumentTitle(RouteHistory),
610+ body: placeholderHTML(snapshot.route),
611+ }, snapshot.shellTheme),
612+ }, nil
613+ }
614+
615+ window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
616+ if err != nil {
617+ return historyRouteResult{}, err
618+ }
619+ stats, err := snapshot.history.Stats(ctx)
620+ if err != nil {
621+ return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
622+ }
623+ domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
624+ if domainsErr != nil {
625+ domains = nil
626+ }
627+ result := historyRouteResult{
628+ entries: entries,
629+ stats: stats,
630+ domains: domains,
631+ windowBefore: snapshot.windowBefore,
632+ windowAfter: snapshot.windowAfter,
633+ }
634+ if window != nil {
635+ result.windowBefore = window.Before
636+ result.windowAfter = window.After
637+ result.hasMore = window.HasMore
638+ }
639+ data := historyRenderData{
640+ Entries: entries,
641+ Stats: stats,
642+ Analytics: nil,
643+ Domains: domains,
644+ Query: snapshot.query,
645+ DomainFilter: snapshot.domainFilter,
646+ Offset: snapshot.offset,
647+ Limit: historyTimelineLimit,
648+ WindowBefore: result.windowBefore,
649+ WindowAfter: result.windowAfter,
650+ HasMore: result.hasMore,
651+ Notice: snapshot.notice,
652+ Error: snapshot.error,
653+ }
654+ title := historyDocumentTitle(data)
655+ result.html = renderAppFrame(renderedPage{
656+ route: RouteHistory,
657+ title: title,
658+ subtitle: "History",
659+ subtitleDetail: historyTitleDetail(data),
660+ body: historyHTML(data),
661+ }, snapshot.shellTheme)
662+ return result, nil
663+}
664+
665+func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666+ query := strings.TrimSpace(snapshot.query)
667+ domain := strings.TrimSpace(snapshot.domainFilter)
668+ if query != "" {
669+ entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670+ return nil, entries, err
671+ }
672+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
673+ if err != nil {
674+ return nil, nil, err
675+ }
676+ if window == nil {
677+ return nil, nil, nil
678+ }
679+ return window, window.Entries, nil
680+}
681+
682+func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
683+ a.favorites = nil
684+ a.folders = nil
685+ a.tags = nil
686+ a.historyEntries = result.entries
687+ a.historyStats = result.stats
688+ a.historyAnalytics = nil
689+ a.historyDomainStats = result.domains
690+ a.historyWindowBefore = result.windowBefore
691+ a.historyWindowAfter = result.windowAfter
692+ a.historyHasMore = result.hasMore
693+ a.renderedHTML = result.html
694+}
695+
696+func (a *App) loadFavoritesRoute(ctx context.Context) error {
697+ if a.deps.Favorites == nil {
698+ a.resetRouteState()
699+ a.renderedHTML = renderAppFrame(renderedPage{
700+ route: a.currentRoute,
701+ title: routeDocumentTitle(RouteFavorites),
702+ subtitle: "Saved bookmarks",
703+ body: placeholderHTML(a.currentRoute),
704+ }, a.shellTheme)
705+ return nil
706+ }
707+
708+ favorites, err := a.deps.Favorites.List(ctx)
709+ if err != nil {
710+ return err
711+ }
712+ folders, err := a.deps.Favorites.ListFolders(ctx)
713+ if err != nil {
714+ return err
715+ }
716+ tags, err := a.deps.Favorites.ListTags(ctx)
717+ if err != nil {
718+ return err
719+ }
720+
721+ a.historyEntries = nil
722+ favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
723+ a.favorites = favorites
724+ a.folders = folders
725+ a.tags = tags
726+ data := favoritesRenderData{
727+ Favorites: favorites,
728+ Folders: folders,
729+ Tags: tags,
730+ FolderFilter: a.favoriteFolderFilter,
731+ TagFilter: a.favoriteTagFilter,
732+ Notice: a.favoritesNotice,
733+ Error: a.favoritesError,
734+ }
735+ a.renderedHTML = renderAppFrame(renderedPage{
736+ route: RouteFavorites,
737+ title: favoritesDocumentTitle(data),
738+ subtitle: "Saved bookmarks",
739+ body: favoritesHTML(data),
740+ }, a.shellTheme)
741+ return nil
742+}
743+
744+func (a *App) loadConfigRoute(ctx context.Context) error {
745+ if a.deps.Config == nil {
746+ a.resetRouteState()
747+ a.renderedHTML = renderAppFrame(renderedPage{
748+ route: a.currentRoute,
749+ title: routeDocumentTitle(RouteConfig),
750+ subtitle: "Browser settings",
751+ body: placeholderHTML(a.currentRoute),
752+ }, a.shellTheme)
753+ return nil
754+ }
755+
756+ config, err := a.deps.Config.Current(ctx)
757+ if err != nil {
758+ return err
759+ }
760+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
761+ if err != nil {
762+ return err
763+ }
764+
765+ a.historyEntries = nil
766+ a.favorites = nil
767+ a.folders = nil
768+ a.tags = nil
769+ a.config = &config
770+ a.keybindings = keybindings
771+ a.renderedHTML = renderAppFrame(renderedPage{
772+ route: RouteConfig,
773+ title: "Config — Dumber",
774+ subtitle: "Browser settings",
775+ body: configHTML(configRenderData{
776+ Config: config,
777+ Keybindings: keybindings,
778+ Notice: a.configNotice,
779+ Error: a.configError,
780+ }),
781+ }, a.shellTheme)
782+ return nil
783+}
784+
785+func (a *App) loadShellTheme(ctx context.Context) {
786+ if a == nil {
787+ return
788+ }
789+
790+ a.shellTheme = shellTheme{}
791+ if a.deps.Config == nil {
792+ return
793+ }
794+
795+ config, err := a.deps.Config.Current(ctx)
796+ if err == nil {
797+ a.shellTheme = resolveShellTheme(config.Appearance)
798+ }
799+}
800+
801+func (a *App) resetRouteState() {
802+ a.historyEntries = nil
803+ a.historyStats = nil
804+ a.historyAnalytics = nil
805+ a.historyDomainStats = nil
806+ a.historyQuery = ""
807+ a.historyDomainFilter = ""
808+ a.historyOffset = 0
809+ a.historyWindowBefore = time.Time{}
810+ a.historyWindowAfter = time.Time{}
811+ a.historyHasMore = false
812+ a.historyNotice = ""
813+ a.historyError = ""
814+ a.favorites = nil
815+ a.folders = nil
816+ a.tags = nil
817+ a.favoriteFolderFilter = nil
818+ a.favoriteTagFilter = nil
819+ a.favoritesNotice = ""
820+ a.favoritesError = ""
821+ a.config = nil
822+ a.keybindings = port.KeybindingsConfig{}
823+ a.configNotice = ""
824+ a.configError = ""
825+}
826+
827+func (a *App) CurrentRoute() Route {
828+ if a == nil {
829+ return RouteUnknown
830+ }
831+ return a.currentRoute
832+}
A internal/ui/systemviews/app_test.go +1432 -0
@@ -0,0 +1,1432 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestParseRoute(t *testing.T) {
18+ t.Parallel()
19+
20+ tests := []struct {
21+ name string
22+ uri string
23+ want Route
24+ }{
25+ {name: "empty", uri: "", want: RouteUnknown},
26+ {name: "scheme only", uri: "://", want: RouteUnknown},
27+ {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown},
28+ {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown},
29+ {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown},
30+ {name: "history host", uri: "dumb://history", want: RouteHistory},
31+ {name: "history opaque", uri: "dumb:history", want: RouteHistory},
32+ {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
33+ {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites},
34+ {name: "config host", uri: "dumb://config", want: RouteConfig},
35+ {name: "config opaque", uri: "dumb:config", want: RouteConfig},
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ t.Parallel()
41+ if got := ParseRoute(tt.uri); got != tt.want {
42+ t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want)
43+ }
44+ })
45+ }
46+}
47+
48+func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
49+ t.Parallel()
50+
51+ dom := &fakeDOM{}
52+ app := NewApp(Dependencies{
53+ DOM: dom,
54+ LocationURI: "dumb://history",
55+ })
56+
57+ require.NoError(t, app.Run())
58+ assert.Equal(t, RouteHistory, app.CurrentRoute())
59+ assert.True(t, dom.Mounted())
60+ assert.Contains(t, dom.HTML(), "history")
61+ assert.Contains(t, dom.HTML(), "systemviews")
62+}
63+
64+func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
65+ t.Parallel()
66+
67+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
68+ URL: "https://example.com",
69+ Title: "Example",
70+ }}}
71+
72+ app := NewApp(Dependencies{
73+ History: history,
74+ LocationURI: "dumb://history",
75+ })
76+
77+ require.NoError(t, app.LoadInitial(context.Background()))
78+ assert.Equal(t, RouteHistory, app.CurrentRoute())
79+ assert.Len(t, app.historyEntries, 1)
80+ assert.Contains(t, app.renderedHTML, "Example")
81+ assert.Contains(t, app.renderedHTML, "https://example.com")
82+ assert.True(t, history.called)
83+ assert.False(t, history.analyticsCalled)
84+ assert.Equal(t, 0, history.limit)
85+ assert.Equal(t, 0, history.offset)
86+
87+ // Shell frame present, no full document wrapper.
88+ assert.NotContains(t, app.renderedHTML, "<html")
89+ assert.NotContains(t, app.renderedHTML, "<head")
90+ assert.NotContains(t, app.renderedHTML, "<body")
91+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
92+ assert.Contains(t, app.renderedHTML, `data-route="history"`)
93+ assert.Contains(t, app.renderedHTML, `data-page-title="History - 1 entry, 1 visit, 0 days"`)
94+ assert.Contains(t, app.renderedHTML, `sv-section`)
95+ assert.Contains(t, app.renderedHTML, `class="sv-list"`)
96+}
97+
98+func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) {
99+ t.Parallel()
100+
101+ history := &fakeHistoryService{
102+ entries: []*entity.HistoryEntry{
103+ {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2},
104+ {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3},
105+ },
106+ stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121},
107+ }
108+ app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
109+
110+ require.NoError(t, app.LoadInitial(context.Background()))
111+ assert.True(t, history.statsCalled)
112+ assert.Contains(t, app.renderedHTML, "Loaded 2 items")
113+ assert.Contains(t, app.renderedHTML, `data-page-title="History - 8904 entries, 40947 visits, 121 days"`)
114+ assert.Contains(t, app.renderedHTML, `<h1 class="sv-title">History <span class="sv-title-detail">- 8904 entries, 40947 visits, 121 days</span></h1>`)
115+ assert.NotContains(t, app.renderedHTML, `sv-history-summary`)
116+ assert.NotContains(t, app.renderedHTML, `sv-stat-value`)
117+}
118+
119+func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) {
120+ t.Parallel()
121+
122+ dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
123+ history := &fakeHistoryService{
124+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}},
125+ timelineStarted: make(chan struct{}),
126+ releaseTimeline: make(chan struct{}),
127+ }
128+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
129+
130+ require.NoError(t, app.RunWithContext(context.Background()))
131+ loadingHTML := receiveMount(t, dom.mounts)
132+ assert.Contains(t, loadingHTML, "Loading history")
133+ assert.NotContains(t, loadingHTML, "Loaded entry")
134+
135+ select {
136+ case <-history.timelineStarted:
137+ case <-time.After(time.Second):
138+ t.Fatal("history timeline was not started asynchronously")
139+ }
140+ close(history.releaseTimeline)
141+
142+ hydratedHTML := receiveMount(t, dom.mounts)
143+ assert.Contains(t, hydratedHTML, "Loaded entry")
144+ assert.Contains(t, hydratedHTML, "Loaded 1 item")
145+}
146+
147+func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
148+ t.Parallel()
149+
150+ dom := &fakeDOM{mounts: make(chan string, 4)}
151+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
152+ app.currentRoute = RouteHistory
153+ staleHTML := "<div>Stale hydrated entry</div>"
154+ staleGeneration := app.renderGeneration
155+ ctx := context.Background()
156+
157+ app.surfaceActionError(ctx, errors.New("action worker failed"))
158+ errorHTML := receiveMount(t, dom.mounts)
159+ assert.Contains(t, errorHTML, "action worker failed")
160+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
161+
162+ require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
164+ assert.Contains(t, dom.HTML(), "action worker failed")
165+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
166+}
167+
168+func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
169+ t.Parallel()
170+
171+ dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
172+ history := &fakeHistoryService{
173+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}},
174+ timelineStarted: make(chan struct{}),
175+ releaseTimeline: make(chan struct{}),
176+ }
177+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
178+ ctx := context.Background()
179+
180+ require.NoError(t, app.RunWithContext(ctx))
181+ loadingHTML := receiveMount(t, dom.mounts)
182+ assert.Contains(t, loadingHTML, "Loading history")
183+
184+ select {
185+ case <-history.timelineStarted:
186+ case <-time.After(time.Second):
187+ t.Fatal("history timeline was not started asynchronously")
188+ }
189+
190+ app.surfaceActionError(ctx, errors.New("action worker failed"))
191+ errorHTML := receiveMount(t, dom.mounts)
192+ assert.Contains(t, errorHTML, "action worker failed")
193+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
194+
195+ close(history.releaseTimeline)
196+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
197+ assert.Contains(t, dom.HTML(), "action worker failed")
198+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
199+}
200+
201+func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
202+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
203+ app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"})
204+ app.currentRoute = RouteHistory
205+ app.historyWindowAfter = cursor
206+
207+ snapshot := app.currentHistoryRouteSnapshotLocked()
208+ assert.Equal(t, cursor, snapshot.windowAfter)
209+}
210+
211+func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
212+ t.Parallel()
213+
214+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215+ history := &fakeHistoryService{window: &entity.HistoryWindow{
216+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217+ Before: cursor,
218+ After: cursor.Add(-24 * time.Hour),
219+ HasMore: true,
220+ }}
221+ dom := &fakeActionDOM{}
222+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
223+ app.currentRoute = RouteHistory
224+ app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
225+ app.historyWindowAfter = cursor
226+ app.historyHasMore = true
227+
228+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
229+ Action: historyActionLoadMore,
230+ Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
231+ }))
232+ assert.Equal(t, cursor, history.windowBefore)
233+ assert.Len(t, app.historyEntries, 2)
234+ assert.Contains(t, dom.AppendedHTML(), "Older entry")
235+ assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
236+ assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page")
237+}
238+
239+func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
240+ t.Parallel()
241+
242+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
243+ history := &fakeHistoryService{window: &entity.HistoryWindow{
244+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
245+ Before: cursor,
246+ After: cursor.Add(-24 * time.Hour),
247+ HasMore: true,
248+ }}
249+ dom := &fakeDOM{}
250+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
251+ app.currentRoute = RouteHistory
252+ app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
253+ app.historyWindowAfter = cursor
254+ app.historyHasMore = true
255+
256+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
257+ Action: historyActionLoadMore,
258+ Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
259+ }))
260+ assert.Equal(t, cursor, history.windowBefore)
261+ assert.Len(t, app.historyEntries, 2)
262+ assert.Contains(t, dom.HTML(), "Older entry")
263+ assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264+}
265+
266+func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
267+ t.Parallel()
268+
269+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
270+ history := &fakeHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}}
271+ dom := &fakeActionDOM{}
272+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
273+ app.currentRoute = RouteHistory
274+ app.historyWindowAfter = cursor
275+ app.historyHasMore = true
276+
277+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
278+ Action: historyActionLoadMore,
279+ Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},
280+ }))
281+ assert.False(t, history.called)
282+ assert.Empty(t, dom.AppendedHTML())
283+}
284+
285+func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
286+ t.Parallel()
287+
288+ history := &fakeHistoryService{
289+ entries: []*entity.HistoryEntry{{
290+ ID: 42,
291+ URL: "https://example.com/page",
292+ Title: "Example",
293+ }},
294+ domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}},
295+ }
296+
297+ app := NewApp(Dependencies{
298+ History: history,
299+ LocationURI: "dumb://history",
300+ })
301+
302+ require.NoError(t, app.LoadInitial(context.Background()))
303+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`)
304+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`)
305+ assert.Contains(t, app.renderedHTML, `data-id="42"`)
306+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`)
307+ assert.Contains(t, app.renderedHTML, `data-range="hour"`)
308+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
309+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
310+ assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`)
311+ assert.Contains(t, app.renderedHTML, `>example.com</button>`)
312+ assert.Contains(t, app.renderedHTML, `data-sv-history-search data-sv-autofocus`)
313+ assert.NotContains(t, app.renderedHTML, "Keys:")
314+}
315+
316+func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
317+ dom := &fakeDOM{}
318+ history := &fakeHistoryService{
319+ entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}},
320+ searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}},
321+ }
322+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
323+
324+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
325+ Action: historyActionSearch,
326+ Data: map[string]string{"query": " example "},
327+ }))
328+ assert.True(t, history.searchCalled)
329+ assert.Equal(t, "example", history.query)
330+ assert.Contains(t, dom.HTML(), "Search result")
331+ assert.Contains(t, dom.HTML(), "Query: example")
332+
333+ app.historyOffset = historyTimelineLimit
334+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
335+ Action: historyActionDeleteEntry,
336+ Data: map[string]string{"id": "42"},
337+ }))
338+ assert.Equal(t, int64(42), history.deletedEntryID)
339+ assert.Equal(t, 0, app.historyOffset)
340+ assert.Contains(t, dom.HTML(), "Deleted history entry")
341+
342+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
343+ Action: historyActionDeleteRange,
344+ Data: map[string]string{"range": "week"},
345+ }))
346+ assert.Equal(t, "week", history.deletedRangeID)
347+ assert.Contains(t, dom.HTML(), "Deleted history from this week")
348+
349+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
350+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
351+ Action: historyActionFilterDomain,
352+ Data: map[string]string{"domain": "example.com"},
353+ }))
354+ assert.True(t, history.called)
355+ assert.Equal(t, "example.com", history.windowDomain)
356+ assert.Empty(t, app.historyQuery)
357+
358+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
359+ Action: historyActionDeleteDomain,
360+ Data: map[string]string{"domain": "example.com"},
361+ }))
362+ assert.Equal(t, "example.com", history.deletedDomain)
363+ assert.Contains(t, dom.HTML(), "Deleted history for example.com")
364+}
365+
366+func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
367+ dom := &fakeActionDOM{}
368+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
369+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
370+
371+ ctx, cancel := context.WithCancel(context.Background())
372+ defer cancel()
373+ require.NoError(t, app.RunWithContext(ctx))
374+ require.NotNil(t, dom.handler)
375+
376+ app.Close()
377+ assert.True(t, dom.released)
378+ assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
379+ require.NotPanics(t, func() {
380+ dom.handler(DOMAction{Action: historyActionClear})
381+ })
382+}
383+
384+func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) {
385+ bindErr := errors.New("bind failed")
386+ dom := &failingActionDOM{bindErr: bindErr}
387+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
388+
389+ err := app.RunWithContext(context.Background())
390+ require.ErrorIs(t, err, bindErr)
391+ assert.Nil(t, app.actionQueue)
392+ assert.Nil(t, app.actionErrorQueue)
393+ assert.Nil(t, app.actionCtx)
394+ assert.False(t, app.actionWorkerActive())
395+ require.NotPanics(t, app.Close)
396+}
397+
398+func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) {
399+ dom := &fakeActionDOM{}
400+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
401+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
402+
403+ ctx, cancel := context.WithCancel(context.Background())
404+ cancel()
405+ err := app.RunWithContext(ctx)
406+
407+ require.ErrorContains(t, err, "systemview action worker unavailable")
408+ assert.True(t, dom.released)
409+ assert.Nil(t, app.actionQueue)
410+ assert.Nil(t, app.actionErrorQueue)
411+ assert.Nil(t, app.actionCtx)
412+ assert.False(t, app.actionWorkerActive())
413+}
414+
415+func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
416+ t.Parallel()
417+
418+ history := &fakeHistoryService{err: errors.New("database unavailable")}
419+ app := NewApp(Dependencies{
420+ History: history,
421+ LocationURI: "dumb://history",
422+ })
423+
424+ require.NoError(t, app.LoadInitial(context.Background()))
425+ assert.Equal(t, RouteHistory, app.CurrentRoute())
426+ assert.True(t, history.called)
427+ assert.Contains(t, app.renderedHTML, "Could not load this system view")
428+ assert.Contains(t, app.renderedHTML, "database unavailable")
429+ assert.Contains(t, app.renderedHTML, `role="alert"`)
430+}
431+
432+func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
433+ t.Parallel()
434+
435+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
436+ URL: "https://example.com",
437+ Title: "Example",
438+ }}}
439+ config := &fakeConfigService{current: dto.SystemviewConfigPayload{
440+ Appearance: dto.WebUIAppearanceConfig{
441+ ColorScheme: "prefer-light",
442+ LightPalette: dto.ColorPalette{
443+ Background: "#ffffff",
444+ Surface: "#fafafa",
445+ SurfaceVariant: "#eeeeee",
446+ Text: "#111111",
447+ Muted: "#666666",
448+ Accent: "#0055ff",
449+ Border: "#dddddd",
450+ },
451+ DarkPalette: dto.ColorPalette{
452+ Background: "#111111",
453+ Surface: "#1a1a1a",
454+ SurfaceVariant: "#2a2a2a",
455+ Text: "#f5f5f5",
456+ Muted: "#a0a0a0",
457+ Accent: "#66aaff",
458+ Border: "#333333",
459+ },
460+ SansFont: "Inter",
461+ SerifFont: "Georgia",
462+ MonospaceFont: "JetBrains Mono",
463+ DefaultFontSize: 16,
464+ },
465+ }}
466+
467+ app := NewApp(Dependencies{
468+ Config: config,
469+ History: history,
470+ LocationURI: "dumb://history",
471+ })
472+
473+ require.NoError(t, app.LoadInitial(context.Background()))
474+ assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
475+ assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
476+ assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
477+}
478+
479+func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
480+ t.Parallel()
481+
482+ favorites := &fakeFavoritesService{
483+ favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
484+ folders: []*entity.Folder{{Name: "Read Later"}},
485+ tags: []*entity.Tag{{Name: "Go"}},
486+ }
487+
488+ app := NewApp(Dependencies{
489+ Favorites: favorites,
490+ LocationURI: "dumb://favorites",
491+ })
492+
493+ require.NoError(t, app.LoadInitial(context.Background()))
494+ assert.Equal(t, RouteFavorites, app.CurrentRoute())
495+ assert.Len(t, app.favorites, 1)
496+ assert.Len(t, app.folders, 1)
497+ assert.Len(t, app.tags, 1)
498+ assert.Contains(t, app.renderedHTML, "Favorites")
499+ assert.Contains(t, app.renderedHTML, "Example")
500+ assert.Contains(t, app.renderedHTML, "Read Later")
501+ assert.Contains(t, app.renderedHTML, "Go")
502+ assert.True(t, favorites.calledList)
503+ assert.True(t, favorites.calledFolders)
504+ assert.True(t, favorites.calledTags)
505+
506+ // Shell frame present, no full document wrapper.
507+ assert.NotContains(t, app.renderedHTML, "<html")
508+ assert.NotContains(t, app.renderedHTML, "<head")
509+ assert.NotContains(t, app.renderedHTML, "<body")
510+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
511+ assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
512+ assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
513+ assert.Contains(t, app.renderedHTML, `sv-section`)
514+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
515+}
516+
517+func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
518+ t.Parallel()
519+
520+ folderID := entity.FolderID(1)
521+ shortcut := 3
522+ favorites := &fakeFavoritesService{
523+ favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}},
524+ folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}},
525+ tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
526+ }
527+
528+ app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"})
529+
530+ require.NoError(t, app.LoadInitial(context.Background()))
531+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
532+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
533+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
534+ assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`)
535+ assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`)
536+ assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
537+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
538+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
539+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
540+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
541+ assert.Contains(t, app.renderedHTML, `name="tags"`)
542+ assert.Contains(t, app.renderedHTML, `value="7"`)
543+ assert.Contains(t, app.renderedHTML, "Read Later")
544+ assert.Contains(t, app.renderedHTML, "Go")
545+ assert.Contains(t, app.renderedHTML, "Shortcut 3")
546+}
547+
548+func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
549+ dom := &fakeDOM{}
550+ favorites := &fakeFavoritesService{
551+ favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
552+ folders: []*entity.Folder{{ID: 5, Name: "Read Later"}},
553+ tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
554+ }
555+ app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
556+
557+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
558+ Action: favoriteActionCreate,
559+ Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"},
560+ }))
561+ require.NotNil(t, favorites.createdFavorite.FolderID)
562+ assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
563+ assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
564+ assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
565+ assert.Contains(t, dom.HTML(), "Added favorite New")
566+
567+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
568+ Action: favoriteActionUpdate,
569+ Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"},
570+ }))
571+ assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
572+ assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
573+ require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
574+ assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
575+ assert.Contains(t, dom.HTML(), "Saved favorite Updated")
576+
577+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
578+ Action: tagActionAssign,
579+ Data: map[string]string{"favoriteId": "42", "tagId": "7"},
580+ }))
581+ assert.Equal(t, int64(42), favorites.assignedFavoriteID)
582+ assert.Equal(t, int64(7), favorites.assignedTagID)
583+
584+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
585+ Action: favoriteActionFilterFolder,
586+ Data: map[string]string{"folderId": "5"},
587+ }))
588+ require.NotNil(t, app.favoriteFolderFilter)
589+ assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
590+ assert.Nil(t, app.favoriteTagFilter)
591+
592+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
593+ Action: favoriteActionFilterTag,
594+ Data: map[string]string{"tagId": "7"},
595+ }))
596+ require.NotNil(t, app.favoriteTagFilter)
597+ assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
598+ assert.Nil(t, app.favoriteFolderFilter)
599+
600+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
601+ Action: folderActionCreate,
602+ Data: map[string]string{"name": "Reading", "icon": "📚"},
603+ }))
604+ assert.Equal(t, "Reading", favorites.createdFolder)
605+ assert.Equal(t, "📚", favorites.createdFolderIcon)
606+ assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
607+
608+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
609+ Action: favoriteActionDelete,
610+ Data: map[string]string{"id": "42"},
611+ }))
612+ assert.Equal(t, int64(42), favorites.deletedFavorite)
613+ assert.Contains(t, dom.HTML(), "Deleted favorite")
614+}
615+
616+func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
617+ t.Parallel()
618+
619+ config := dto.SystemviewConfigPayload{
620+ EngineType: "webkit",
621+ Appearance: dto.WebUIAppearanceConfig{
622+ ColorScheme: "prefer-dark",
623+ SansFont: "Inter",
624+ SerifFont: "Georgia",
625+ MonospaceFont: "JetBrains Mono",
626+ DefaultFontSize: 16,
627+ LightPalette: dto.ColorPalette{
628+ Background: "#ffffff",
629+ Surface: "#fafafa",
630+ SurfaceVariant: "#eeeeee",
631+ Text: "#111111",
632+ Muted: "#666666",
633+ Accent: "#0055ff",
634+ Border: "#dddddd",
635+ },
636+ DarkPalette: dto.ColorPalette{
637+ Background: "#111111",
638+ Surface: "#1a1a1a",
639+ SurfaceVariant: "#2a2a2a",
640+ Text: "#f5f5f5",
641+ Muted: "#a0a0a0",
642+ Accent: "#66aaff",
643+ Border: "#333333",
644+ },
645+ },
646+ Performance: dto.SystemviewPerformancePayload{
647+ Profile: "balanced",
648+ Custom: dto.SystemviewCustomPerformancePayload{
649+ SkiaCPUThreads: 4,
650+ SkiaGPUThreads: 2,
651+ WebProcessMemoryMB: 512,
652+ NetworkProcessMemoryMB: 128,
653+ WebViewPoolPrewarm: 1,
654+ },
655+ Hardware: dto.SystemviewHardwarePayload{
656+ CPUCores: 8,
657+ CPUThreads: 16,
658+ TotalRAMMB: 32768,
659+ GPUVendor: "NVIDIA",
660+ GPUName: "RTX 4060",
661+ VRAMMB: 8192,
662+ },
663+ },
664+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
665+ SearchShortcuts: map[string]dto.SearchShortcut{
666+ "ddg": {
667+ URL: "https://duckduckgo.com/?q=%s",
668+ Description: "DuckDuckGo",
669+ },
670+ },
671+ }
672+ service := &fakeConfigService{
673+ current: config,
674+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
675+ {
676+ Mode: "default",
677+ DisplayName: "Default",
678+ Bindings: []port.KeybindingEntry{
679+ {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
680+ {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
681+ },
682+ },
683+ {
684+ Mode: "search",
685+ DisplayName: "Search",
686+ Bindings: []port.KeybindingEntry{
687+ {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
688+ },
689+ },
690+ }},
691+ }
692+
693+ app := NewApp(Dependencies{
694+ Config: service,
695+ LocationURI: "dumb://config",
696+ })
697+
698+ require.NoError(t, app.LoadInitial(context.Background()))
699+ assert.Equal(t, RouteConfig, app.CurrentRoute())
700+ require.NotNil(t, app.config)
701+ assert.Equal(t, "webkit", app.config.EngineType)
702+ assert.Contains(t, app.renderedHTML, "webkit")
703+ assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
704+ assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
705+ assert.Contains(t, app.renderedHTML, "prefer-dark")
706+ assert.Contains(t, app.renderedHTML, "appearance.sans_font")
707+ assert.Contains(t, app.renderedHTML, "Inter")
708+ assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
709+ assert.Contains(t, app.renderedHTML, "#ffffff")
710+ assert.Contains(t, app.renderedHTML, "performance.profile")
711+ assert.Contains(t, app.renderedHTML, "balanced")
712+ assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
713+ assert.Contains(t, app.renderedHTML, "4")
714+ assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
715+ assert.Contains(t, app.renderedHTML, "NVIDIA")
716+ assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
717+ assert.Contains(t, app.renderedHTML, "Default")
718+ assert.Contains(t, app.renderedHTML, "Search")
719+ assert.Contains(t, app.renderedHTML, "Open")
720+ assert.Contains(t, app.renderedHTML, "ctrl+o")
721+ assert.Contains(t, app.renderedHTML, "New tab")
722+ assert.Contains(t, app.renderedHTML, "ctrl+t")
723+ assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
724+ assert.Contains(t, app.renderedHTML, "Find")
725+ assert.Contains(t, app.renderedHTML, "open")
726+ assert.Contains(t, app.renderedHTML, "default")
727+ assert.Contains(t, app.renderedHTML, "custom")
728+ assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
729+ assert.True(t, service.calledCurrent)
730+ assert.True(t, service.calledKeybindings)
731+
732+ // Shell frame present, no full document wrapper.
733+ assert.NotContains(t, app.renderedHTML, "<html")
734+ assert.NotContains(t, app.renderedHTML, "<head")
735+ assert.NotContains(t, app.renderedHTML, "<body")
736+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
737+ assert.Contains(t, app.renderedHTML, `data-route="config"`)
738+ assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
739+ assert.Contains(t, app.renderedHTML, `sv-section`)
740+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
741+}
742+
743+func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
744+ t.Parallel()
745+
746+ service := &fakeConfigService{
747+ current: testConfigPayload(),
748+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
749+ Mode: "default",
750+ DisplayName: "Default",
751+ Bindings: []port.KeybindingEntry{{
752+ Action: "toggle_history_systemview",
753+ Description: "Toggle history",
754+ Keys: []string{"ctrl+h"},
755+ DefaultKeys: []string{"ctrl+h"},
756+ }},
757+ }}},
758+ }
759+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
760+
761+ require.NoError(t, app.LoadInitial(context.Background()))
762+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
763+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
764+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
765+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
766+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
767+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
768+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
769+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
770+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
771+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
772+ assert.Contains(t, app.renderedHTML, "may require restarting")
773+}
774+
775+func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
776+ dom := &fakeDOM{}
777+ service := &fakeConfigService{
778+ current: testConfigPayload(),
779+ defaultCfg: testDefaultConfigPayload(),
780+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
781+ Mode: "default",
782+ DisplayName: "Default",
783+ Bindings: []port.KeybindingEntry{{
784+ Action: "toggle_history_systemview",
785+ Description: "Toggle history",
786+ Keys: []string{"ctrl+h"},
787+ DefaultKeys: []string{"ctrl+h"},
788+ }},
789+ }}},
790+ setResp: port.SetKeybindingResponse{},
791+ }
792+ app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
793+
794+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
795+ Action: configActionSaveAppearance,
796+ Data: map[string]string{
797+ "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
798+ "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
799+ "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
800+ "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
801+ },
802+ }))
803+ assert.True(t, service.calledSave)
804+ assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805+ assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
806+ assert.Contains(t, dom.HTML(), "Saved appearance settings")
807+ assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808+
809+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
810+ Action: configActionCreateSearchShortcut,
811+ Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
812+ }))
813+ assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
814+ assert.Contains(t, dom.HTML(), "Created search shortcut g")
815+
816+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
817+ Action: configActionSavePerformance,
818+ Data: map[string]string{
819+ "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
820+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
821+ },
822+ }))
823+ assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
824+ assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
825+ assert.Contains(t, dom.HTML(), "Saved performance settings")
826+
827+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
828+ Action: configActionSavePerformance,
829+ Data: map[string]string{"profile": "default"},
830+ }))
831+ assert.Equal(t, "default", service.savedConfig.Performance.Profile)
832+ assert.Contains(t, dom.HTML(), "Saved performance settings")
833+
834+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
835+ Action: configActionSetKeybinding,
836+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
837+ }))
838+ assert.True(t, service.calledSet)
839+ assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
840+ assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
841+ assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
842+
843+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
844+ Action: configActionResetKeybinding,
845+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
846+ }))
847+ assert.True(t, service.calledReset)
848+ assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
849+ assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
850+ assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
851+ assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
852+
853+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
854+ assert.True(t, service.calledResetAll)
855+ assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
856+}
857+
858+func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
859+ t.Parallel()
860+
861+ service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()}
862+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
863+
864+ err := app.handleConfigAction(context.Background(), DOMAction{
865+ Action: configActionSaveSearch,
866+ Data: map[string]string{"default_search_engine": "https://example.com/search"},
867+ })
868+ require.Error(t, err)
869+ assert.False(t, service.calledSave)
870+
871+ err = app.handleConfigAction(context.Background(), DOMAction{
872+ Action: configActionCreateSearchShortcut,
873+ Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"},
874+ })
875+ require.Error(t, err)
876+ assert.False(t, service.calledSave)
877+}
878+
879+func testConfigPayload() dto.SystemviewConfigPayload {
880+ return dto.SystemviewConfigPayload{
881+ EngineType: "webkit",
882+ Appearance: dto.WebUIAppearanceConfig{
883+ ColorScheme: "prefer-dark",
884+ SansFont: "Inter",
885+ SerifFont: "Georgia",
886+ MonospaceFont: "JetBrains Mono",
887+ DefaultFontSize: 16,
888+ LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
889+ DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
890+ },
891+ DefaultUIScale: 1,
892+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
893+ SearchShortcuts: map[string]dto.SearchShortcut{
894+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
895+ },
896+ Performance: dto.SystemviewPerformancePayload{
897+ Profile: "default",
898+ Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
899+ Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
900+ },
901+ }
902+}
903+
904+func testDefaultConfigPayload() dto.SystemviewConfigPayload {
905+ cfg := testConfigPayload()
906+ cfg.Appearance.DefaultFontSize = 14
907+ cfg.DefaultUIScale = 1
908+ return cfg
909+}
910+
911+// Handwritten fake to capture DOM mounts for stateful render assertions.
912+type fakeDOM struct {
913+ mu sync.Mutex
914+ mounted bool
915+ html string
916+ appendedHTML string
917+ mounts chan string
918+}
919+
920+func (d *fakeDOM) Mount(markup string) error {
921+ d.mu.Lock()
922+ d.mounted = true
923+ d.html = markup
924+ mounts := d.mounts
925+ d.mu.Unlock()
926+ if mounts != nil {
927+ select {
928+ case mounts <- markup:
929+ default:
930+ }
931+ }
932+ return nil
933+}
934+
935+func (d *fakeDOM) Mounted() bool {
936+ d.mu.Lock()
937+ defer d.mu.Unlock()
938+ return d.mounted
939+}
940+
941+func (d *fakeDOM) HTML() string {
942+ d.mu.Lock()
943+ defer d.mu.Unlock()
944+ return d.html
945+}
946+
947+func (d *fakeDOM) AppendedHTML() string {
948+ d.mu.Lock()
949+ defer d.mu.Unlock()
950+ return d.appendedHTML
951+}
952+
953+func receiveMount(t *testing.T, mounts <-chan string) string {
954+ t.Helper()
955+ select {
956+ case html := <-mounts:
957+ return html
958+ case <-time.After(time.Second):
959+ t.Fatal("timed out waiting for DOM mount")
960+ return ""
961+ }
962+}
963+
964+func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
965+ t.Helper()
966+ select {
967+ case html := <-mounts:
968+ t.Fatalf("unexpected DOM mount: %s", html)
969+ case <-time.After(wait):
970+ }
971+}
972+
973+type fakeActionDOM struct {
974+ fakeDOM
975+ handler DOMActionHandler
976+ released bool
977+}
978+
979+func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error {
980+ d.handler = handler
981+ return nil
982+}
983+
984+func (d *fakeActionDOM) AppendHistoryTimeline(markup string) error {
985+ d.mu.Lock()
986+ d.appendedHTML += markup
987+ d.mu.Unlock()
988+ return nil
989+}
990+
991+func (d *fakeActionDOM) Release() {
992+ d.released = true
993+}
994+
995+type failingActionDOM struct {
996+ fakeDOM
997+ bindErr error
998+}
999+
1000+func (d *failingActionDOM) BindActions(DOMActionHandler) error {
1001+ return d.bindErr
1002+}
1003+
1004+// Handwritten fake to capture history state for stateful render assertions.
1005+type fakeHistoryService struct {
1006+ called bool
1007+ limit int
1008+ offset int
1009+ entries []*entity.HistoryEntry
1010+ err error
1011+ domainCalled bool
1012+ domain string
1013+
1014+ searchCalled bool
1015+ query string
1016+ searchLimit int
1017+ searchEntries []*entity.HistoryEntry
1018+
1019+ deletedEntryID int64
1020+ deletedRangeID string
1021+ deletedDomain string
1022+ domainStats []*entity.DomainStat
1023+ stats *entity.HistoryStats
1024+ statsCalled bool
1025+ window *entity.HistoryWindow
1026+ windowBefore time.Time
1027+ windowDomain string
1028+ analyticsCalled bool
1029+
1030+ timelineStarted chan struct{}
1031+ releaseTimeline chan struct{}
1032+ startOnce sync.Once
1033+}
1034+
1035+func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
1036+ s.called = true
1037+ s.limit = limit
1038+ s.offset = offset
1039+ if s.timelineStarted != nil {
1040+ s.startOnce.Do(func() { close(s.timelineStarted) })
1041+ }
1042+ if s.releaseTimeline != nil {
1043+ <-s.releaseTimeline
1044+ }
1045+ return s.entries, s.err
1046+}
1047+
1048+func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
1049+ s.domainCalled = true
1050+ s.domain = domain
1051+ s.limit = limit
1052+ s.offset = offset
1053+ return s.entries, s.err
1054+}
1055+
1056+func (s *fakeHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
1057+ s.called = true
1058+ s.windowBefore = before
1059+ s.windowDomain = domain
1060+ if s.timelineStarted != nil {
1061+ s.startOnce.Do(func() { close(s.timelineStarted) })
1062+ }
1063+ if s.releaseTimeline != nil {
1064+ <-s.releaseTimeline
1065+ }
1066+ if s.err != nil {
1067+ return nil, s.err
1068+ }
1069+ if s.window != nil {
1070+ return s.window, nil
1071+ }
1072+ return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil
1073+}
1074+
1075+func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
1076+ s.searchCalled = true
1077+ s.query = query
1078+ s.searchLimit = limit
1079+ if s.searchEntries != nil {
1080+ return s.searchEntries, nil
1081+ }
1082+ return s.entries, nil
1083+}
1084+
1085+func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error {
1086+ s.deletedEntryID = id
1087+ return nil
1088+}
1089+
1090+func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error {
1091+ s.deletedRangeID = rangeID
1092+ return nil
1093+}
1094+
1095+func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) {
1096+ s.statsCalled = true
1097+ return s.stats, nil
1098+}
1099+
1100+func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
1101+ s.analyticsCalled = true
1102+ return nil, nil
1103+}
1104+
1105+func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
1106+ return s.domainStats, nil
1107+}
1108+
1109+func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error {
1110+ s.deletedDomain = domain
1111+ return nil
1112+}
1113+
1114+// Handwritten fake to capture favorites state for stateful render assertions.
1115+type fakeFavoritesService struct {
1116+ calledList bool
1117+ calledFolders bool
1118+ calledTags bool
1119+ favorites []*entity.Favorite
1120+ folders []*entity.Folder
1121+ tags []*entity.Tag
1122+
1123+ createdFavorite dto.FavoriteCreateInput
1124+ updatedFavorite dto.FavoriteUpdateInput
1125+ deletedFavorite int64
1126+ createdFolder string
1127+ createdFolderIcon string
1128+ updatedFolderID int64
1129+ deletedFolderID int64
1130+ createdTag string
1131+ updatedTagID int64
1132+ deletedTagID int64
1133+ assignedFavoriteID int64
1134+ assignedTagID int64
1135+ removedFavoriteID int64
1136+ removedTagID int64
1137+}
1138+
1139+func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
1140+ s.calledList = true
1141+ return s.favorites, nil
1142+}
1143+
1144+func (s *fakeFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
1145+ s.calledFolders = true
1146+ return s.folders, nil
1147+}
1148+
1149+func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
1150+ s.calledTags = true
1151+ return s.tags, nil
1152+}
1153+
1154+func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1155+ s.createdFavorite = input
1156+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
1157+}
1158+
1159+func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1160+ s.updatedFavorite = input
1161+ return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil
1162+}
1163+
1164+func (s *fakeFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
1165+ s.deletedFavorite = id
1166+ return nil
1167+}
1168+
1169+func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
1170+
1171+func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
1172+
1173+func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) {
1174+ s.createdFolder = name
1175+ s.createdFolderIcon = icon
1176+ return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
1177+}
1178+
1179+func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
1180+ s.updatedFolderID = id
1181+ return nil
1182+}
1183+
1184+func (s *fakeFavoritesService) DeleteFolder(_ context.Context, id int64) error {
1185+ s.deletedFolderID = id
1186+ return nil
1187+}
1188+
1189+func (s *fakeFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
1190+ s.createdTag = name
1191+ return &entity.Tag{ID: 55, Name: name, Color: color}, nil
1192+}
1193+
1194+func (s *fakeFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error {
1195+ s.updatedTagID = id
1196+ return nil
1197+}
1198+
1199+func (s *fakeFavoritesService) DeleteTag(_ context.Context, id int64) error {
1200+ s.deletedTagID = id
1201+ return nil
1202+}
1203+
1204+func (s *fakeFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error {
1205+ s.assignedFavoriteID = favoriteID
1206+ s.assignedTagID = tagID
1207+ return nil
1208+}
1209+
1210+func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error {
1211+ s.removedFavoriteID = favoriteID
1212+ s.removedTagID = tagID
1213+ return nil
1214+}
1215+
1216+// Handwritten fake to capture config state for stateful render assertions.
1217+type fakeConfigService struct {
1218+ calledCurrent bool
1219+ calledDefault bool
1220+ calledSave bool
1221+ calledKeybindings bool
1222+ calledSet bool
1223+ calledReset bool
1224+ calledResetAll bool
1225+
1226+ current dto.SystemviewConfigPayload
1227+ defaultCfg dto.SystemviewConfigPayload
1228+ keybindings port.KeybindingsConfig
1229+ savedConfig dto.WebUIConfig
1230+ setReq port.SetKeybindingRequest
1231+ setResp port.SetKeybindingResponse
1232+ resetReq port.ResetKeybindingRequest
1233+}
1234+
1235+func (s *fakeConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
1236+ s.calledCurrent = true
1237+ return s.current, nil
1238+}
1239+
1240+func (s *fakeConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
1241+ s.calledDefault = true
1242+ return s.defaultCfg, nil
1243+}
1244+
1245+func (s *fakeConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
1246+ s.calledSave = true
1247+ s.savedConfig = cfg
1248+ s.current.Appearance = cfg.Appearance
1249+ s.current.DefaultUIScale = cfg.DefaultUIScale
1250+ s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
1251+ s.current.SearchShortcuts = cfg.SearchShortcuts
1252+ s.current.Performance.Profile = cfg.Performance.Profile
1253+ s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
1254+ SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
1255+ SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
1256+ WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
1257+ NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
1258+ WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
1259+ }
1260+ return nil
1261+}
1262+
1263+func (s *fakeConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
1264+ s.calledKeybindings = true
1265+ return s.keybindings, nil
1266+}
1267+
1268+func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
1269+ s.calledSet = true
1270+ s.setReq = req
1271+ return s.setResp, nil
1272+}
1273+
1274+func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
1275+ s.calledReset = true
1276+ s.resetReq = req
1277+ return nil
1278+}
1279+
1280+func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
1281+ s.calledResetAll = true
1282+ return nil
1283+}
1284+
1285+func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) {
1286+ app := NewApp(Dependencies{Favorites: &fakeFavoritesService{}})
1287+
1288+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1289+ Action: favoriteActionCreate,
1290+ Data: map[string]string{"url": " ", "title": "Blank"},
1291+ })
1292+ require.Error(t, err)
1293+ assert.Contains(t, err.Error(), "favorite URL is required")
1294+
1295+ err = app.handleFavoriteAction(context.Background(), DOMAction{
1296+ Action: favoriteActionCreate,
1297+ Data: map[string]string{"url": "example.com", "title": "Relative"},
1298+ })
1299+ require.Error(t, err)
1300+ assert.Contains(t, err.Error(), "favorite URL must be absolute")
1301+
1302+ err = app.handleFavoriteAction(context.Background(), DOMAction{
1303+ Action: favoriteActionCreate,
1304+ Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"},
1305+ })
1306+ require.Error(t, err)
1307+ assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme")
1308+}
1309+
1310+func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1311+ favorites := &fakeFavoritesService{}
1312+ app := NewApp(Dependencies{Favorites: favorites})
1313+
1314+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1315+ Action: favoriteActionCreate,
1316+ Data: map[string]string{"url": "dumb:history", "title": "History"},
1317+ }))
1318+ assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
1319+
1320+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1321+ Action: favoriteActionCreate,
1322+ Data: map[string]string{"url": "dumb://config", "title": "Config"},
1323+ }))
1324+ assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1325+}
1326+
1327+func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1328+ favorites := &fakeFavoritesService{}
1329+ app := NewApp(Dependencies{Favorites: favorites})
1330+
1331+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1332+ Action: favoriteActionCreate,
1333+ Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1334+ })
1335+
1336+ require.Error(t, err)
1337+ assert.Contains(t, err.Error(), "known systemview route")
1338+}
1339+
1340+func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1341+ _, err := parsePositiveInt64("abc", "tag id")
1342+ require.Error(t, err)
1343+ assert.Contains(t, err.Error(), "invalid tag id")
1344+ assert.Contains(t, err.Error(), "invalid syntax")
1345+
1346+ _, err = parsePositiveInt64("0", "tag id")
1347+ require.Error(t, err)
1348+ assert.Contains(t, err.Error(), "tag id must be a positive integer")
1349+}
1350+
1351+func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1352+ favorites := &fakeFavoritesService{}
1353+ app := NewApp(Dependencies{Favorites: favorites})
1354+
1355+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1356+ Action: tagActionAssign,
1357+ Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1358+ }))
1359+ assert.Equal(t, int64(42), favorites.assignedFavoriteID)
1360+ assert.Equal(t, int64(7), favorites.assignedTagID)
1361+
1362+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1363+ Action: tagActionRemove,
1364+ Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1365+ }))
1366+ assert.Equal(t, int64(42), favorites.removedFavoriteID)
1367+ assert.Equal(t, int64(7), favorites.removedTagID)
1368+}
1369+
1370+func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1371+ dom := &fakeDOM{}
1372+ history := &fakeHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}}
1373+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
1374+ app.historyDomainFilter = "example.com"
1375+
1376+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
1377+ Action: historyActionSearch,
1378+ Data: map[string]string{"query": "search"},
1379+ }))
1380+
1381+ assert.Equal(t, "search", app.historyQuery)
1382+ assert.Empty(t, app.historyDomainFilter)
1383+ assert.True(t, history.searchCalled)
1384+ assert.False(t, history.domainCalled)
1385+}
1386+
1387+func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1388+ app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})
1389+
1390+ var ctx context.Context
1391+ err := app.RunWithContext(ctx)
1392+
1393+ require.ErrorContains(t, err, "context is nil")
1394+}
1395+
1396+func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) {
1397+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
1398+ app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
1399+
1400+ err := app.RunWithContext(context.Background())
1401+
1402+ require.ErrorContains(t, err, "DOM not configured")
1403+ assert.False(t, history.called)
1404+}
1405+
1406+func TestHandleActionsRejectUnknownAction(t *testing.T) {
1407+ app := NewApp(Dependencies{
1408+ History: &fakeHistoryService{},
1409+ Favorites: &fakeFavoritesService{},
1410+ Config: &fakeConfigService{current: testConfigPayload()},
1411+ })
1412+
1413+ require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action")
1414+ require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action")
1415+ require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action")
1416+}
1417+
1418+func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) {
1419+ service := &fakeConfigService{current: testConfigPayload()}
1420+ app := NewApp(Dependencies{Config: service})
1421+
1422+ err := app.handleConfigAction(context.Background(), DOMAction{
1423+ Action: configActionSavePerformance,
1424+ Data: map[string]string{
1425+ "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2",
1426+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
1427+ },
1428+ })
1429+
1430+ require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8")
1431+ assert.False(t, service.calledSave)
1432+}
A internal/ui/systemviews/components.templ +96 -0
@@ -0,0 +1,96 @@
1+package systemviews
2+
3+templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) {
4+ <div class={ appRootClass(theme) } data-route={ string(page.route) } data-page-title={ pageDocumentTitle(page) }
5+ if theme.InlineVars != "" {
6+ style={ theme.InlineVars }
7+ }
8+ >
9+ <div class="sv-shell">
10+ if page.subtitle != "" {
11+ <h1 class="sv-title">
12+ { page.subtitle }
13+ if page.subtitleDetail != "" {
14+ <span class="sv-title-detail">{ page.subtitleDetail }</span>
15+ }
16+ </h1>
17+ }
18+ <div class="sv-content">
19+ @body
20+ </div>
21+ </div>
22+ </div>
23+}
24+
25+templ Section(className string, title string) {
26+ <section class={ sectionClass(className) }>
27+ <h2>{ title }</h2>
28+ { children... }
29+ </section>
30+}
31+
32+templ Meta(text string) {
33+ <p class="sv-meta">{ text }</p>
34+}
35+
36+templ EmptyState(message string) {
37+ <div class="sv-empty"><p>{ message }</p></div>
38+}
39+
40+templ LoadingPanel(message string) {
41+ <div class="sv-loading-panel" role="status" aria-live="polite">
42+ <span class="sv-spinner" aria-hidden="true"></span>
43+ <span>{ message }</span>
44+ </div>
45+}
46+
47+templ LoadingCard(label string) {
48+ <div class="sv-stat-card sv-loading-card" role="status" aria-live="polite">
49+ <span class="sv-spinner" aria-hidden="true"></span>
50+ <span class="sv-stat-label">{ label }</span>
51+ </div>
52+}
53+
54+templ Alert(kind string, message string) {
55+ if message != "" {
56+ <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div>
57+ }
58+}
59+
60+templ List() {
61+ <ul class="sv-list">
62+ { children... }
63+ </ul>
64+}
65+
66+templ ListRow() {
67+ <li class="sv-list-row">
68+ { children... }
69+ </li>
70+}
71+
72+templ Button(label string, action string, classes string, attrs templ.Attributes, disabled bool) {
73+ <button type="button" class={ buttonClass(classes) } data-sv-action={ action } disabled?={ disabled } { attrs... }>{ label }</button>
74+}
75+
76+templ KeyList(keys []string) {
77+ if len(keys) == 0 {
78+ <span class="sv-key sv-key-empty">Unassigned</span>
79+ } else {
80+ for _, key := range keys {
81+ <span class="sv-key">{ key }</span>
82+ }
83+ }
84+}
85+
86+templ KV(rows []kvPair) {
87+ if len(rows) == 0 {
88+ @EmptyState("No configuration details")
89+ } else {
90+ <dl class="sv-kv">
91+ for _, row := range rows {
92+ <div class="sv-kv-row"><dt>{ row.Label }</dt><dd>{ row.Value }</dd></div>
93+ }
94+ </dl>
95+ }
96+}
A internal/ui/systemviews/components_templ.go +755 -0 generated file: collapsed
A internal/ui/systemviews/config.templ +198 -0
@@ -0,0 +1,198 @@
1+package systemviews
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/dto"
5+ "github.com/bnema/dumber/internal/application/port"
6+)
7+
8+templ ConfigView(data configRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-config-appearance", "Appearance") {
12+ @AppearanceForm(data.Config)
13+ }
14+ @Section("sv-config-search", "Search") {
15+ @SearchSettings(data.Config)
16+ }
17+ @Section("sv-config-performance", "Performance") {
18+ @PerformanceSettings(data.Config)
19+ }
20+ @Section("sv-config-keybindings", "Keybindings") {
21+ @Meta(configKeybindingSummary(data.Keybindings))
22+ @ConfigKeybindings(data.Keybindings)
23+ }
24+ @Section("sv-config-values", "Current payload") {
25+ @KV(flattenValues(data.Config))
26+ }
27+}
28+
29+templ AppearanceForm(config dto.SystemviewConfigPayload) {
30+ <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">
31+ <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>
32+ <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>
33+ <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label>
34+ <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label>
35+ <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label>
36+ <label><span>Color scheme</span><select name="color_scheme">
37+ <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option>
38+ <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>
39+ <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>
40+ </select></label>
41+ @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
42+ @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
43+ <div class="sv-button-row sv-form-actions">
44+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save appearance")</button>
45+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset appearance")</button>
46+ </div>
47+ </form>
48+}
49+
50+templ PaletteFields(title string, prefix string, palette dto.ColorPalette) {
51+ <fieldset class="sv-fieldset">
52+ <legend>{ title }</legend>
53+ <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>
54+ <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>
55+ <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>
56+ <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>
57+ <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>
58+ <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>
59+ <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label>
60+ </fieldset>
61+}
62+
63+templ SearchSettings(config dto.SystemviewConfigPayload) {
64+ <form class="sv-card-form" data-sv-action="config.search.save">
65+ <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label>
66+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save search default")</button>
67+ </form>
68+ <div class="sv-group">
69+ <div class="sv-group-header"><h4>Search shortcuts</h4></div>
70+ <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>
71+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
72+ <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
73+ <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label>
74+ <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
75+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconPlus, "Add shortcut")</button>
76+ </form>
77+ {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }}
78+ if len(shortcuts) == 0 {
79+ @EmptyState("No search shortcuts configured")
80+ } else {
81+ <ul class="sv-list">
82+ for _, shortcut := range shortcuts {
83+ @SearchShortcutItem(shortcut)
84+ }
85+ </ul>
86+ }
87+ </div>
88+}
89+
90+templ SearchShortcutItem(shortcut searchShortcutRow) {
91+ <li class="sv-list-row">
92+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">
93+ <input type="hidden" name="key" value={ shortcut.Key }/>
94+ <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
95+ <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label>
96+ <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>
97+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
98+ <button class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?" aria-label={ "Delete search shortcut " + shortcut.Key }>@IconOnly(iconTrash, "Delete search shortcut " + shortcut.Key)</button>
99+ </form>
100+ </li>
101+}
102+
103+templ PerformanceSettings(config dto.SystemviewConfigPayload) {
104+ {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
105+ <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>
106+ <label><span>Profile</span><select name="profile" data-sv-performance-profile>
107+ <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
108+ <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option>
109+ <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
110+ <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
111+ <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
112+ </select></label>
113+ <p class="sv-meta">Choose Custom to edit individual performance values.</p>
114+ <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 } data-sv-performance-custom/></label>
115+ <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 } data-sv-performance-custom/></label>
116+ <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 } data-sv-performance-custom/></label>
117+ <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 } data-sv-performance-custom/></label>
118+ <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 } data-sv-performance-custom/></label>
119+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button>
120+ </form>
121+ <div class="sv-stat-grid">
122+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div>
123+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div>
124+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div>
125+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div>
126+ </div>
127+ if !engineIsWebKit(config.EngineType) {
128+ <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p>
129+ } else {
130+ <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p>
131+ }
132+}
133+
134+templ ConfigKeybindings(keybindings port.KeybindingsConfig) {
135+ {{ groups := configKeybindingGroups(keybindings) }}
136+ if groups == nil {
137+ @EmptyState("Keybindings unavailable")
138+ } else if len(groups) == 0 {
139+ @EmptyState("No keybindings configured")
140+ } else {
141+ <div class="sv-button-row">
142+ <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset all keybindings")</button>
143+ </div>
144+ for _, group := range groups {
145+ @ConfigKeybindingGroup(group)
146+ }
147+ }
148+}
149+
150+templ ConfigKeybindingGroup(group renderedKeybindingGroup) {
151+ <div class="sv-group">
152+ <div class="sv-group-header">
153+ <div>
154+ <h4>{ keybindingGroupTitle(group) }</h4>
155+ if keybindingGroupMeta(group) != "" {
156+ <p class="sv-meta">{ keybindingGroupMeta(group) }</p>
157+ }
158+ </div>
159+ </div>
160+ <div class="sv-group-body">
161+ if len(group.Bindings) == 0 {
162+ @EmptyState("No bindings in this group")
163+ } else {
164+ <ul class="sv-list">
165+ for _, binding := range group.Bindings {
166+ @ConfigKeybinding(group, binding)
167+ }
168+ </ul>
169+ }
170+ </div>
171+ </div>
172+}
173+
174+templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) {
175+ <li class="sv-list-row">
176+ <div class="sv-keybinding-row">
177+ <div>
178+ <strong>{ keybindingDescription(binding) }</strong>
179+ <p class="sv-meta">{ keybindingAction(binding) }</p>
180+ <div class="sv-binding-keys">
181+ @KeyList(binding.Keys)
182+ <span class="sv-badge">{ keybindingStatus(binding) }</span>
183+ if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
184+ <span class="sv-meta">default:</span>
185+ @KeyList(binding.DefaultKeys)
186+ }
187+ </div>
188+ </div>
189+ <form class="sv-inline-form" data-sv-action="config.keybinding.set">
190+ <input type="hidden" name="mode" value={ group.Mode }/>
191+ <input type="hidden" name="action" value={ binding.Action }/>
192+ <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label>
193+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
194+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>@ButtonIconLabel(iconRotateCCW, "Reset")</button>
195+ </form>
196+ </div>
197+ </li>
198+}
A internal/ui/systemviews/config_actions.go +493 -0
@@ -0,0 +1,493 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+ "net/url"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+
12+ "github.com/bnema/dumber/internal/application/dto"
13+ "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+const (
17+ configActionSaveAppearance = "config.appearance.save"
18+ configActionResetAppearance = "config.appearance.reset"
19+ configActionSaveSearch = "config.search.save"
20+ configActionCreateSearchShortcut = "config.searchShortcut.create"
21+ configActionUpdateSearchShortcut = "config.searchShortcut.update"
22+ configActionDeleteSearchShortcut = "config.searchShortcut.delete"
23+ configActionSavePerformance = "config.performance.save"
24+ configActionSetKeybinding = "config.keybinding.set"
25+ configActionResetKeybinding = "config.keybinding.reset"
26+ configActionResetAllKeybindings = "config.keybinding.resetAll"
27+ configRequestIDPrefix = "systemviews-config"
28+ balancedPerformanceProfile = "balanced"
29+ customPerformanceProfile = "custom"
30+ httpScheme = "http"
31+ maxCustomPerformanceThreads = 8
32+ maxCustomPerformanceWebMemoryMB = 16384
33+ maxCustomPerformanceNetworkMemoryMB = 4096
34+ maxCustomPerformancePrewarm = 20
35+)
36+
37+var configRequestCounter atomic.Uint64
38+
39+func nextConfigRequestID() string {
40+ return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1))
41+}
42+
43+func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
44+ if a.deps.Config == nil {
45+ return fmt.Errorf("config service not configured")
46+ }
47+
48+ switch event.Action {
49+ case configActionSaveAppearance:
50+ return a.saveAppearanceConfig(ctx, event.Data)
51+ case configActionResetAppearance:
52+ return a.resetAppearanceConfig(ctx)
53+ case configActionSaveSearch:
54+ return a.saveSearchConfig(ctx, event.Data)
55+ case configActionCreateSearchShortcut:
56+ return a.createSearchShortcut(ctx, event.Data)
57+ case configActionUpdateSearchShortcut:
58+ return a.updateSearchShortcut(ctx, event.Data)
59+ case configActionDeleteSearchShortcut:
60+ return a.deleteSearchShortcut(ctx, event.Data)
61+ case configActionSavePerformance:
62+ return a.savePerformanceConfig(ctx, event.Data)
63+ case configActionSetKeybinding:
64+ return a.setConfigKeybinding(ctx, event.Data)
65+ case configActionResetKeybinding:
66+ return a.resetConfigKeybinding(ctx, event.Data)
67+ case configActionResetAllKeybindings:
68+ if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil {
69+ return err
70+ }
71+ a.configNotice = "Reset all keybindings to defaults"
72+ return nil
73+ default:
74+ return fmt.Errorf("unknown config action: %q", event.Action)
75+ }
76+}
77+
78+func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
79+ cfg, err := a.editableConfig(ctx)
80+ if err != nil {
81+ return err
82+ }
83+
84+ fontSize, err := parseConfigInt(data["default_font_size"], "default font size")
85+ if err != nil {
86+ return err
87+ }
88+ uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale")
89+ if err != nil {
90+ return err
91+ }
92+ if fontSize <= 0 {
93+ return fmt.Errorf("default font size must be positive")
94+ }
95+ if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) {
96+ return fmt.Errorf("UI scale must be a finite positive number")
97+ }
98+
99+ cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
100+ cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
101+ cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
102+ cfg.Appearance.DefaultFontSize = fontSize
103+ cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
104+ cfg.Appearance.LightPalette = paletteFromForm(data, "light")
105+ cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
106+ cfg.DefaultUIScale = uiScale
107+
108+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
109+ return err
110+ }
111+ a.configNotice = "Saved appearance settings"
112+ return nil
113+}
114+
115+func (a *App) resetAppearanceConfig(ctx context.Context) error {
116+ cfg, err := a.editableConfig(ctx)
117+ if err != nil {
118+ return err
119+ }
120+ defaults, err := a.deps.Config.Default(ctx)
121+ if err != nil {
122+ return err
123+ }
124+ cfg.Appearance = defaults.Appearance
125+ cfg.DefaultUIScale = defaults.DefaultUIScale
126+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
127+ return err
128+ }
129+ a.configNotice = "Reset appearance settings to defaults"
130+ return nil
131+}
132+
133+func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error {
134+ cfg, err := a.editableConfig(ctx)
135+ if err != nil {
136+ return err
137+ }
138+ defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine")
139+ if err != nil {
140+ return err
141+ }
142+ cfg.DefaultSearchEngine = defaultSearchEngine
143+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
144+ return err
145+ }
146+ a.configNotice = "Saved default search engine"
147+ return nil
148+}
149+
150+func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error {
151+ cfg, err := a.editableConfig(ctx)
152+ if err != nil {
153+ return err
154+ }
155+ key, shortcut := searchShortcutFromForm(data, "key")
156+ if key == "" {
157+ return fmt.Errorf("search shortcut key is required")
158+ }
159+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
160+ return err
161+ }
162+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
163+ if _, exists := cfg.SearchShortcuts[key]; exists {
164+ return fmt.Errorf("search shortcut %q already exists", key)
165+ }
166+ cfg.SearchShortcuts[key] = shortcut
167+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
168+ return err
169+ }
170+ a.configNotice = "Created search shortcut " + key
171+ return nil
172+}
173+
174+func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error {
175+ cfg, err := a.editableConfig(ctx)
176+ if err != nil {
177+ return err
178+ }
179+ oldKey := strings.TrimSpace(data["key"])
180+ if oldKey == "" {
181+ return fmt.Errorf("search shortcut key is required")
182+ }
183+ newKey, shortcut := searchShortcutFromForm(data, "new_key")
184+ if newKey == "" {
185+ return fmt.Errorf("new search shortcut key is required")
186+ }
187+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
188+ return err
189+ }
190+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
191+ if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
192+ return fmt.Errorf("search shortcut %q not found", oldKey)
193+ }
194+ if newKey != oldKey {
195+ if _, exists := cfg.SearchShortcuts[newKey]; exists {
196+ return fmt.Errorf("search shortcut %q already exists", newKey)
197+ }
198+ delete(cfg.SearchShortcuts, oldKey)
199+ }
200+ cfg.SearchShortcuts[newKey] = shortcut
201+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
202+ return err
203+ }
204+ a.configNotice = "Saved search shortcut " + newKey
205+ return nil
206+}
207+
208+func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error {
209+ cfg, err := a.editableConfig(ctx)
210+ if err != nil {
211+ return err
212+ }
213+ key := strings.TrimSpace(data["key"])
214+ if key == "" {
215+ return fmt.Errorf("search shortcut key is required")
216+ }
217+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
218+ if _, exists := cfg.SearchShortcuts[key]; !exists {
219+ return fmt.Errorf("search shortcut %q not found", key)
220+ }
221+ delete(cfg.SearchShortcuts, key)
222+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
223+ return err
224+ }
225+ a.configNotice = "Deleted search shortcut " + key
226+ return nil
227+}
228+
229+func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error {
230+ cfg, err := a.editableConfig(ctx)
231+ if err != nil {
232+ return err
233+ }
234+ profile := strings.TrimSpace(data["profile"])
235+ if !isAllowedPerformanceProfile(profile) {
236+ return fmt.Errorf("unknown performance profile: %q", profile)
237+ }
238+ cfg.Performance.Profile = profile
239+ if profile != customPerformanceProfile {
240+ if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil {
241+ return saveErr
242+ }
243+ a.configNotice = "Saved performance settings. Restart may be required."
244+ return nil
245+ }
246+
247+ skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
248+ if err != nil {
249+ return err
250+ }
251+ skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads")
252+ if err != nil {
253+ return err
254+ }
255+ webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory")
256+ if err != nil {
257+ return err
258+ }
259+ networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory")
260+ if err != nil {
261+ return err
262+ }
263+ prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm")
264+ if err != nil {
265+ return err
266+ }
267+ if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil {
268+ return err
269+ }
270+ if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil {
271+ return err
272+ }
273+ if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil {
274+ return err
275+ }
276+ if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil {
277+ return err
278+ }
279+ if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil {
280+ return err
281+ }
282+
283+ cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{
284+ SkiaCPUThreads: skiaCPU,
285+ SkiaGPUThreads: skiaGPU,
286+ WebProcessMemoryMB: webMemory,
287+ NetworkProcessMemoryMB: networkMemory,
288+ WebViewPoolPrewarm: prewarm,
289+ }
290+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
291+ return err
292+ }
293+ a.configNotice = "Saved performance settings. Restart may be required."
294+ return nil
295+}
296+
297+func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error {
298+ mode := strings.TrimSpace(data["mode"])
299+ action := strings.TrimSpace(data["action"])
300+ keys := parseKeyList(data["keys"])
301+ if mode == "" || action == "" {
302+ return fmt.Errorf("mode and action are required")
303+ }
304+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
305+ return err
306+ }
307+ resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
308+ RequestID: nextConfigRequestID(),
309+ Mode: mode,
310+ Action: action,
311+ Keys: keys,
312+ })
313+ if err != nil {
314+ return err
315+ }
316+ if conflicts := len(resp.Conflicts); conflicts > 0 {
317+ a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
318+ return nil
319+ }
320+ a.configNotice = "Saved keybinding " + action
321+ return nil
322+}
323+
324+func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error {
325+ mode := strings.TrimSpace(data["mode"])
326+ action := strings.TrimSpace(data["action"])
327+ if mode == "" || action == "" {
328+ return fmt.Errorf("mode and action are required")
329+ }
330+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
331+ return err
332+ }
333+ if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
334+ RequestID: nextConfigRequestID(),
335+ Mode: mode,
336+ Action: action,
337+ }); err != nil {
338+ return err
339+ }
340+ a.configNotice = "Reset keybinding " + action
341+ return nil
342+}
343+
344+func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) {
345+ cfg, err := a.deps.Config.Current(ctx)
346+ if err != nil {
347+ return dto.WebUIConfig{}, err
348+ }
349+ a.config = &cfg
350+ return webUIConfigFromPayload(cfg), nil
351+}
352+
353+func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error {
354+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
355+ if err != nil {
356+ return err
357+ }
358+ a.keybindings = keybindings
359+ groups := configKeybindingGroups(keybindings)
360+ for _, group := range groups {
361+ if group.Mode != mode {
362+ continue
363+ }
364+ for _, binding := range group.Bindings {
365+ if binding.Action == action {
366+ return nil
367+ }
368+ }
369+ }
370+ return fmt.Errorf("keybinding %s/%s not found", mode, action)
371+}
372+
373+func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error {
374+ if err := a.deps.Config.Save(ctx, cfg); err != nil {
375+ return err
376+ }
377+ a.config = nil
378+ return nil
379+}
380+
381+func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig {
382+ return dto.WebUIConfig{
383+ Appearance: payload.Appearance,
384+ DefaultUIScale: payload.DefaultUIScale,
385+ DefaultSearchEngine: payload.DefaultSearchEngine,
386+ SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
387+ Performance: dto.WebUIPerformanceConfig{
388+ Profile: payload.Performance.Profile,
389+ Custom: dto.WebUICustomPerformanceConfig{
390+ SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
391+ SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
392+ WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
393+ NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB,
394+ WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm,
395+ },
396+ },
397+ }
398+}
399+
400+func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut {
401+ clone := make(map[string]dto.SearchShortcut, len(shortcuts))
402+ for key, shortcut := range shortcuts {
403+ clone[key] = shortcut
404+ }
405+ return clone
406+}
407+
408+func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette {
409+ color := func(field string) string {
410+ return strings.TrimSpace(data[prefix+"_"+field])
411+ }
412+
413+ return dto.ColorPalette{
414+ Background: color("background"),
415+ Surface: color("surface"),
416+ SurfaceVariant: color("surface_variant"),
417+ Text: color("text"),
418+ Muted: color("muted"),
419+ Accent: color("accent"),
420+ Border: color("border"),
421+ }
422+}
423+
424+func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
425+ return strings.TrimSpace(data[keyField]), dto.SearchShortcut{
426+ URL: strings.TrimSpace(data["url"]),
427+ Description: strings.TrimSpace(data["description"]),
428+ }
429+}
430+
431+func requireSearchURLTemplate(raw, label string) (string, error) {
432+ value := strings.TrimSpace(raw)
433+ if value == "" {
434+ return "", fmt.Errorf("%s is required", label)
435+ }
436+ if !strings.Contains(value, "%s") {
437+ return "", fmt.Errorf("%s must include %%s placeholder", label)
438+ }
439+ parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
440+ if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") {
441+ return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
442+ }
443+ return value, nil
444+}
445+
446+func parseConfigInt(raw, label string) (int, error) {
447+ value, err := strconv.Atoi(strings.TrimSpace(raw))
448+ if err != nil {
449+ return 0, fmt.Errorf("invalid %s", label)
450+ }
451+ return value, nil
452+}
453+
454+func parseConfigFloat(raw, label string) (float64, error) {
455+ value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
456+ if err != nil {
457+ return 0, fmt.Errorf("invalid %s", label)
458+ }
459+ return value, nil
460+}
461+
462+func isAllowedPerformanceProfile(profile string) bool {
463+ switch profile {
464+ case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile:
465+ return true
466+ default:
467+ return false
468+ }
469+}
470+
471+func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
472+ if value < minValue || value > maxValue {
473+ return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
474+ }
475+ return nil
476+}
477+
478+func parseKeyList(raw string) []string {
479+ raw = strings.TrimSpace(raw)
480+ if raw == "" {
481+ return nil
482+ }
483+ raw = strings.ReplaceAll(raw, ",", " ")
484+ parts := strings.Fields(raw)
485+ keys := make([]string, 0, len(parts))
486+ for _, part := range parts {
487+ key := strings.TrimSpace(part)
488+ if key != "" {
489+ keys = append(keys, key)
490+ }
491+ }
492+ return keys
493+}
A internal/ui/systemviews/config_templ.go +1332 -0 generated file: collapsed
A internal/ui/systemviews/config_view.go +373 -0
@@ -0,0 +1,373 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "reflect"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type configRenderData struct {
14+ Config dto.SystemviewConfigPayload
15+ Keybindings port.KeybindingsConfig
16+ Notice string
17+ Error string
18+}
19+
20+// maxFlattenDepth caps recursive config flattening to avoid runaway nesting.
21+const maxFlattenDepth = 16
22+
23+func configHTML(data configRenderData) string {
24+ return mustRenderComponent(ConfigView(data))
25+}
26+
27+func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
28+ if keybindings.Groups == nil {
29+ return "Keybindings unavailable"
30+ }
31+ groups, bindings := summarizeKeybindings(keybindings)
32+ return fmt.Sprintf(
33+ "Keybindings loaded (%d %s, %d %s)",
34+ groups,
35+ plural(groups, "group"),
36+ bindings,
37+ plural(bindings, "binding"),
38+ )
39+}
40+
41+func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {
42+ if keybindings.Groups == nil {
43+ return nil
44+ }
45+ return convertPortKeybindingGroups(keybindings.Groups)
46+}
47+
48+func keybindingGroupTitle(group renderedKeybindingGroup) string {
49+ title := strings.TrimSpace(group.DisplayName)
50+ if title == "" {
51+ title = strings.TrimSpace(group.Mode)
52+ }
53+ if title == "" {
54+ return "Keybinding group"
55+ }
56+ return title
57+}
58+
59+func keybindingGroupMeta(group renderedKeybindingGroup) string {
60+ meta := make([]string, 0, 2)
61+ if strings.TrimSpace(group.Mode) != "" {
62+ meta = append(meta, "mode: "+group.Mode)
63+ }
64+ if strings.TrimSpace(group.Activation) != "" {
65+ meta = append(meta, "activation: "+group.Activation)
66+ }
67+ return strings.Join(meta, " • ")
68+}
69+
70+func keybindingDescription(binding renderedKeybinding) string {
71+ description := strings.TrimSpace(binding.Description)
72+ if description != "" {
73+ return description
74+ }
75+ if strings.TrimSpace(binding.Action) != "" {
76+ return strings.TrimSpace(binding.Action)
77+ }
78+ return "Unnamed binding"
79+}
80+
81+func keybindingAction(binding renderedKeybinding) string {
82+ action := strings.TrimSpace(binding.Action)
83+ if action == "" {
84+ return "binding"
85+ }
86+ return action
87+}
88+
89+func keybindingIsCustom(binding renderedKeybinding) bool {
90+ return binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys)
91+}
92+
93+func keybindingStatus(binding renderedKeybinding) string {
94+ if keybindingIsCustom(binding) {
95+ return "custom"
96+ }
97+ return "default"
98+}
99+
100+func keybindingKeysValue(keys []string) string {
101+ return strings.Join(keys, ", ")
102+}
103+
104+type searchShortcutRow struct {
105+ Key string
106+ URL string
107+ Description string
108+}
109+
110+func searchShortcutRows(shortcuts map[string]dto.SearchShortcut) []searchShortcutRow {
111+ if len(shortcuts) == 0 {
112+ return nil
113+ }
114+ keys := make([]string, 0, len(shortcuts))
115+ for key := range shortcuts {
116+ keys = append(keys, key)
117+ }
118+ sort.Strings(keys)
119+ rows := make([]searchShortcutRow, 0, len(keys))
120+ for _, key := range keys {
121+ shortcut := shortcuts[key]
122+ rows = append(rows, searchShortcutRow{
123+ Key: key,
124+ URL: shortcut.URL,
125+ Description: shortcut.Description,
126+ })
127+ }
128+ return rows
129+}
130+
131+func formatConfigInt(value int) string {
132+ return fmt.Sprintf("%d", value)
133+}
134+
135+func formatConfigFloat(value float64) string {
136+ return fmt.Sprintf("%g", value)
137+}
138+
139+func colorSchemeSelected(current, option string) bool {
140+ return strings.EqualFold(strings.TrimSpace(current), option)
141+}
142+
143+func performanceProfileSelected(current, option string) bool {
144+ return strings.EqualFold(strings.TrimSpace(current), option)
145+}
146+
147+func engineIsWebKit(engine string) bool {
148+ return strings.EqualFold(strings.TrimSpace(engine), "webkit")
149+}
150+
151+func flattenValues(value any) []kvPair {
152+ rows := make([]kvPair, 0)
153+ flattenValue(&rows, "", reflect.ValueOf(value), 0)
154+ return rows
155+}
156+
157+func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
158+ if !value.IsValid() {
159+ appendFlatValue(rows, path, "null")
160+ return
161+ }
162+ if depth >= maxFlattenDepth {
163+ appendFlatValue(rows, path, "...")
164+ return
165+ }
166+
167+ for value.Kind() == reflect.Interface {
168+ if value.IsNil() {
169+ appendFlatValue(rows, path, "null")
170+ return
171+ }
172+ value = value.Elem()
173+ }
174+
175+ for value.Kind() == reflect.Pointer {
176+ if value.IsNil() {
177+ appendFlatValue(rows, path, "null")
178+ return
179+ }
180+ value = value.Elem()
181+ }
182+
183+ switch value.Kind() {
184+ case reflect.Struct:
185+ flattenStructValue(rows, path, value, depth)
186+ case reflect.Map:
187+ flattenMapValue(rows, path, value, depth)
188+ case reflect.Slice, reflect.Array:
189+ flattenListValue(rows, path, value, depth)
190+ default:
191+ appendFlatValue(rows, path, formatFlatValue(value))
192+ }
193+}
194+
195+func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
196+ typeOfValue := value.Type()
197+ for i := 0; i < value.NumField(); i++ {
198+ field := typeOfValue.Field(i)
199+ if !field.IsExported() {
200+ continue
201+ }
202+ name := jsonFieldName(field)
203+ if name == "-" {
204+ continue
205+ }
206+ flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1)
207+ }
208+}
209+
210+func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
211+ if value.Len() == 0 {
212+ appendFlatValue(rows, path, "{}")
213+ return
214+ }
215+
216+ keys := value.MapKeys()
217+ sort.Slice(keys, func(i, j int) bool {
218+ return mapKeyString(keys[i]) < mapKeyString(keys[j])
219+ })
220+
221+ for _, key := range keys {
222+ name := mapKeyString(key)
223+ flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1)
224+ }
225+}
226+
227+func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
228+ if value.Len() == 0 {
229+ appendFlatValue(rows, path, "[]")
230+ return
231+ }
232+
233+ for i := 0; i < value.Len(); i++ {
234+ nextPath := fmt.Sprintf("%s[%d]", path, i)
235+ if path == "" {
236+ nextPath = fmt.Sprintf("[%d]", i)
237+ }
238+ flattenValue(rows, nextPath, value.Index(i), depth+1)
239+ }
240+}
241+
242+func childFlatPath(path, name string) string {
243+ if path == "" {
244+ return name
245+ }
246+ return path + "." + name
247+}
248+
249+func appendFlatValue(rows *[]kvPair, path, value string) {
250+ if strings.TrimSpace(path) == "" {
251+ path = "value"
252+ }
253+ *rows = append(*rows, kvPair{Label: path, Value: value})
254+}
255+
256+func formatFlatValue(value reflect.Value) string {
257+ if !value.IsValid() {
258+ return "null"
259+ }
260+
261+ switch value.Kind() {
262+ case reflect.String:
263+ return value.String()
264+ case reflect.Bool:
265+ return fmt.Sprint(value.Bool())
266+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
267+ return fmt.Sprint(value.Int())
268+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
269+ return fmt.Sprint(value.Uint())
270+ case reflect.Float32, reflect.Float64:
271+ return fmt.Sprint(value.Float())
272+ case reflect.Complex64, reflect.Complex128:
273+ return fmt.Sprint(value.Complex())
274+ default:
275+ if !value.CanInterface() {
276+ return "<unexported>"
277+ }
278+ return fmt.Sprint(value.Interface())
279+ }
280+}
281+
282+func jsonFieldName(field reflect.StructField) string {
283+ name := field.Tag.Get("json")
284+ if name == "" {
285+ return field.Name
286+ }
287+ if idx := strings.Index(name, ","); idx >= 0 {
288+ name = name[:idx]
289+ }
290+ if name == "" {
291+ return field.Name
292+ }
293+ return name
294+}
295+
296+func mapKeyString(value reflect.Value) string {
297+ if value.Kind() == reflect.String {
298+ return value.String()
299+ }
300+ if value.CanInterface() {
301+ return fmt.Sprint(value.Interface())
302+ }
303+ return formatFlatValue(value)
304+}
305+
306+func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {
307+ for _, group := range keybindings.Groups {
308+ groups++
309+ bindings += len(group.Bindings)
310+ }
311+ return groups, bindings
312+}
313+
314+func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {
315+ rendered := make([]renderedKeybindingGroup, 0, len(groups))
316+ for _, group := range groups {
317+ rendered = append(rendered, renderedKeybindingGroup{
318+ DisplayName: strings.TrimSpace(group.DisplayName),
319+ Mode: strings.TrimSpace(group.Mode),
320+ Activation: strings.TrimSpace(group.Activation),
321+ Bindings: convertPortKeybindingEntries(group.Bindings),
322+ })
323+ }
324+ return rendered
325+}
326+
327+func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding {
328+ rendered := make([]renderedKeybinding, 0, len(bindings))
329+ for _, binding := range bindings {
330+ rendered = append(rendered, renderedKeybinding{
331+ Description: strings.TrimSpace(binding.Description),
332+ Action: strings.TrimSpace(binding.Action),
333+ Keys: append([]string(nil), binding.Keys...),
334+ DefaultKeys: append([]string(nil), binding.DefaultKeys...),
335+ IsCustom: binding.IsCustom,
336+ })
337+ }
338+ return rendered
339+}
340+
341+type renderedKeybindingGroup struct {
342+ DisplayName string
343+ Mode string
344+ Activation string
345+ Bindings []renderedKeybinding
346+}
347+
348+type renderedKeybinding struct {
349+ Description string
350+ Action string
351+ Keys []string
352+ DefaultKeys []string
353+ IsCustom bool
354+}
355+
356+func sameStringSlice(a, b []string) bool {
357+ if len(a) != len(b) {
358+ return false
359+ }
360+ for i := range a {
361+ if a[i] != b[i] {
362+ return false
363+ }
364+ }
365+ return true
366+}
367+
368+func plural(count int, singular string) string {
369+ if count == 1 {
370+ return singular
371+ }
372+ return singular + "s"
373+}
A internal/ui/systemviews/dom.go +40 -0
@@ -0,0 +1,40 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+)
7+
8+type DOM interface {
9+ Mount(markup string) error
10+}
11+
12+// DOMAction is a browser event delegated from the mounted systemview DOM.
13+type DOMAction struct {
14+ Action string
15+ Data map[string]string
16+}
17+
18+// DOMActionHandler handles delegated browser actions.
19+type DOMActionHandler func(DOMAction)
20+
21+// DOMActionBinder is implemented by DOM adapters that can delegate events back
22+// to the App. Tests can keep using the smaller DOM interface.
23+type DOMActionBinder interface {
24+ BindActions(handler DOMActionHandler) error
25+}
26+
27+// DOMHistoryTimelineAppender is implemented by DOM adapters that can append
28+// older history windows without reparsing and remounting the whole page.
29+type DOMHistoryTimelineAppender interface {
30+ AppendHistoryTimeline(markup string) error
31+}
32+
33+func placeholderHTML(route Route) string {
34+ name := html.EscapeString(string(route))
35+ if name == "" || route == RouteUnknown {
36+ name = "unknown"
37+ }
38+
39+ return fmt.Sprintf(`<p>systemviews %s</p>`, name)
40+}
A internal/ui/systemviews/dom_js.go +819 -0
@@ -0,0 +1,819 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+ "syscall/js"
10+)
11+
12+type browserDOM struct {
13+ document js.Value
14+ target js.Value
15+ bindings []jsEventBinding
16+ historyObserver js.Value
17+ historyObserverCallback js.Func
18+ activeConfirmCleanup func()
19+ activeConfirmID uint64
20+}
21+
22+type jsEventBinding struct {
23+ target js.Value
24+ event string
25+ fn js.Func
26+}
27+
28+func NewDOM() DOM {
29+ doc := js.Global().Get("document")
30+ if !doc.Truthy() {
31+ return &browserDOM{}
32+ }
33+
34+ target := doc.Call("getElementById", "app")
35+ return &browserDOM{document: doc, target: target}
36+}
37+
38+func (d *browserDOM) Mount(markup string) error {
39+ if d == nil || !d.target.Truthy() {
40+ return fmt.Errorf("DOM mount target #app not found")
41+ }
42+ d.target.Set("innerHTML", markup)
43+ d.updateDocumentTitle()
44+ d.scheduleAlertDismissal()
45+ d.syncPerformanceCustomInputStates()
46+ d.focusAutofocusTarget()
47+ d.setupHistoryInfiniteScroll()
48+ return nil
49+}
50+
51+func (d *browserDOM) AppendHistoryTimeline(markup string) error {
52+ if d == nil || !d.target.Truthy() {
53+ return fmt.Errorf("DOM mount target #app not found")
54+ }
55+ timeline := d.target.Call("querySelector", "[data-sv-history-timeline]")
56+ if !timeline.Truthy() {
57+ return fmt.Errorf("history timeline target not found")
58+ }
59+ if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() {
60+ old.Call("remove")
61+ }
62+
63+ container := d.document.Call("createElement", "div")
64+ container.Set("innerHTML", markup)
65+ d.mergeLeadingHistoryGroup(timeline, container)
66+ for container.Get("firstChild").Truthy() {
67+ timeline.Call("appendChild", container.Get("firstChild"))
68+ }
69+ d.setupHistoryInfiniteScroll()
70+ return nil
71+}
72+
73+func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) {
74+ if !timeline.Truthy() || !container.Truthy() {
75+ return
76+ }
77+ first := container.Get("firstElementChild")
78+ if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() {
79+ return
80+ }
81+ date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String())
82+ if date == "" {
83+ return
84+ }
85+ groups := timeline.Call("querySelectorAll", "[data-sv-history-date]")
86+ if !groups.Truthy() || groups.Get("length").Int() == 0 {
87+ return
88+ }
89+ lastGroup := groups.Index(groups.Get("length").Int() - 1)
90+ if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date {
91+ return
92+ }
93+ list := lastGroup.Call("querySelector", "ul")
94+ if !list.Truthy() {
95+ return
96+ }
97+ for first.Get("firstChild").Truthy() {
98+ list.Call("appendChild", first.Get("firstChild"))
99+ }
100+ if first.Get("remove").Truthy() {
101+ first.Call("remove")
102+ }
103+}
104+
105+func (d *browserDOM) updateDocumentTitle() {
106+ if d == nil || !d.document.Truthy() || !d.target.Truthy() {
107+ return
108+ }
109+ root := d.target.Call("querySelector", "[data-page-title]")
110+ if !root.Truthy() {
111+ return
112+ }
113+ title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String())
114+ if title == "" {
115+ return
116+ }
117+ d.document.Set("title", title)
118+}
119+
120+func (d *browserDOM) focusAutofocusTarget() {
121+ if d == nil || !d.target.Truthy() {
122+ return
123+ }
124+ target := d.target.Call("querySelector", "[data-sv-autofocus]")
125+ if !target.Truthy() || !target.Get("focus").Truthy() {
126+ return
127+ }
128+ target.Call("focus")
129+}
130+
131+func (d *browserDOM) scheduleAlertDismissal() {
132+ if d == nil || !d.target.Truthy() {
133+ return
134+ }
135+ alerts := d.target.Call("querySelectorAll", ".sv-alert")
136+ length := alerts.Get("length").Int()
137+ if length == 0 {
138+ return
139+ }
140+ setTimeout := js.Global().Get("setTimeout")
141+ if !setTimeout.Truthy() {
142+ return
143+ }
144+ for i := 0; i < length; i++ {
145+ alert := alerts.Index(i)
146+ if !alert.Truthy() {
147+ continue
148+ }
149+ scheduleAlertRemoval(setTimeout, alert)
150+ }
151+}
152+
153+func scheduleAlertRemoval(setTimeout, alert js.Value) {
154+ var fadeFn js.Func
155+ fadeFn = js.FuncOf(func(js.Value, []js.Value) any {
156+ defer fadeFn.Release()
157+ if !alert.Truthy() || !alert.Get("classList").Truthy() {
158+ return nil
159+ }
160+ alert.Get("classList").Call("add", "sv-alert-dismiss")
161+
162+ var removeFn js.Func
163+ removeFn = js.FuncOf(func(js.Value, []js.Value) any {
164+ defer removeFn.Release()
165+ if alert.Truthy() && alert.Get("remove").Truthy() {
166+ alert.Call("remove")
167+ }
168+ return nil
169+ })
170+ setTimeout.Invoke(removeFn, 220)
171+ return nil
172+ })
173+ setTimeout.Invoke(fadeFn, 4200)
174+}
175+
176+func (d *browserDOM) BindActions(handler DOMActionHandler) error {
177+ if d == nil || !d.target.Truthy() || handler == nil {
178+ return fmt.Errorf("DOM action binding target not available")
179+ }
180+ d.Release()
181+
182+ clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
183+ event := firstJSArg(args)
184+ if !event.Truthy() {
185+ return nil
186+ }
187+ element := closestActionElement(event)
188+ if !element.Truthy() {
189+ return nil
190+ }
191+ if strings.EqualFold(element.Get("tagName").String(), "form") {
192+ target := event.Get("target")
193+ if !target.Truthy() {
194+ target = event.Get("srcElement")
195+ }
196+ if !target.Truthy() || !element.Equal(target) {
197+ return nil
198+ }
199+ }
200+ action := element.Call("getAttribute", "data-sv-action").String()
201+ if action == "" {
202+ return nil
203+ }
204+ event.Call("preventDefault")
205+ data := collectActionData(element)
206+ d.confirmAction(element, func() {
207+ handler(DOMAction{Action: action, Data: data})
208+ })
209+ return nil
210+ })
211+ d.addEventBinding(d.target, "click", clickHandler)
212+
213+ submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
214+ event := firstJSArg(args)
215+ if !event.Truthy() {
216+ return nil
217+ }
218+ form := event.Get("target")
219+ if !form.Truthy() {
220+ return nil
221+ }
222+ action := form.Call("getAttribute", "data-sv-action").String()
223+ if action == "" {
224+ return nil
225+ }
226+ event.Call("preventDefault")
227+ updatePerformanceCustomInputs(form)
228+ data := collectActionData(form)
229+ for key, value := range collectFormData(form) {
230+ data[key] = value
231+ }
232+ d.confirmAction(form, func() {
233+ handler(DOMAction{Action: action, Data: data})
234+ })
235+ return nil
236+ })
237+ d.addEventBinding(d.target, "submit", submitHandler)
238+
239+ changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
240+ event := firstJSArg(args)
241+ if !event.Truthy() {
242+ return nil
243+ }
244+ target := event.Get("target")
245+ if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() {
246+ return nil
247+ }
248+ form := target.Get("form")
249+ if form.Truthy() {
250+ updatePerformanceCustomInputs(form)
251+ }
252+ return nil
253+ })
254+ d.addEventBinding(d.target, "change", changeHandler)
255+
256+ keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
257+ event := firstJSArg(args)
258+ if !d.eventTargetInsideMount(event) {
259+ return nil
260+ }
261+ d.handleKeyboard(event)
262+ return nil
263+ })
264+ d.addEventBinding(d.document, "keydown", keydownHandler)
265+
266+ return nil
267+}
268+
269+func (d *browserDOM) eventTargetInsideMount(event js.Value) bool {
270+ if d == nil || !d.target.Truthy() || !event.Truthy() {
271+ return false
272+ }
273+ target := event.Get("target")
274+ if !target.Truthy() {
275+ target = event.Get("srcElement")
276+ }
277+ if !target.Truthy() {
278+ return false
279+ }
280+ if d.document.Truthy() && target.Equal(d.document) {
281+ return true
282+ }
283+ if strings.EqualFold(target.Get("nodeName").String(), "body") {
284+ return true
285+ }
286+ return d.target.Call("contains", target).Bool()
287+}
288+
289+func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
290+ if !target.Truthy() {
291+ fn.Release()
292+ return
293+ }
294+ target.Call("addEventListener", event, fn)
295+ d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
296+}
297+
298+func (d *browserDOM) setupHistoryInfiniteScroll() {
299+ if d == nil || !d.target.Truthy() {
300+ return
301+ }
302+ // setupHistoryInfiniteScroll callbacks close over the current load-more
303+ // button. disconnectHistoryObserver owns the cleanup chain: it calls
304+ // d.historyObserver.disconnect(), releases d.historyObserverCallback, and
305+ // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new
306+ // observer so old button closures are not retained.
307+ d.disconnectHistoryObserver()
308+ observerCtor := js.Global().Get("IntersectionObserver")
309+ if !observerCtor.Truthy() {
310+ return
311+ }
312+ button := d.target.Call("querySelector", "[data-sv-history-load-more]")
313+ if !button.Truthy() {
314+ return
315+ }
316+ callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
317+ if len(args) == 0 || !button.Truthy() {
318+ return nil
319+ }
320+ entries := args[0]
321+ for i := 0; i < entries.Get("length").Int(); i++ {
322+ entry := entries.Index(i)
323+ if !entry.Truthy() || !entry.Get("isIntersecting").Bool() {
324+ continue
325+ }
326+ if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" {
327+ return nil
328+ }
329+ // A disabled button does not dispatch click events. Dispatch first, then
330+ // mark the control busy so the observer cannot enqueue duplicate loads
331+ // while the WASM action worker fetches the next history window.
332+ button.Call("click")
333+ button.Get("dataset").Set("svLoading", "true")
334+ button.Set("disabled", true)
335+ button.Set("textContent", "Loading older visits…")
336+ return nil
337+ }
338+ return nil
339+ })
340+ d.historyObserverCallback = callback
341+ options := js.Global().Get("Object").New()
342+ options.Set("rootMargin", "400px 0px")
343+ d.historyObserver = observerCtor.New(callback, options)
344+ d.historyObserver.Call("observe", button)
345+}
346+
347+func (d *browserDOM) disconnectHistoryObserver() {
348+ if d == nil {
349+ return
350+ }
351+ if d.historyObserver.Truthy() {
352+ d.historyObserver.Call("disconnect")
353+ d.historyObserver = js.Value{}
354+ }
355+ if d.historyObserverCallback.Truthy() {
356+ d.historyObserverCallback.Release()
357+ d.historyObserverCallback = js.Func{}
358+ }
359+}
360+
361+func (d *browserDOM) Release() {
362+ if d == nil {
363+ return
364+ }
365+ d.disconnectHistoryObserver()
366+ d.cleanupActiveConfirmDialog()
367+ for _, binding := range d.bindings {
368+ if binding.target.Truthy() {
369+ binding.target.Call("removeEventListener", binding.event, binding.fn)
370+ }
371+ binding.fn.Release()
372+ }
373+ d.bindings = nil
374+}
375+
376+func firstJSArg(args []js.Value) js.Value {
377+ if len(args) == 0 {
378+ return js.Value{}
379+ }
380+ return args[0]
381+}
382+
383+func closestActionElement(event js.Value) js.Value {
384+ target := event.Get("target")
385+ if !target.Truthy() || !target.Get("closest").Truthy() {
386+ return js.Value{}
387+ }
388+ return target.Call("closest", "[data-sv-action]")
389+}
390+
391+func collectActionData(element js.Value) map[string]string {
392+ data := map[string]string{}
393+ if !element.Truthy() {
394+ return data
395+ }
396+ dataset := element.Get("dataset")
397+ if !dataset.Truthy() {
398+ return data
399+ }
400+ keys := js.Global().Get("Object").Call("keys", dataset)
401+ for i := 0; i < keys.Get("length").Int(); i++ {
402+ key := keys.Index(i).String()
403+ if key == "svAction" || key == "svConfirm" {
404+ continue
405+ }
406+ data[key] = dataset.Get(key).String()
407+ }
408+ return data
409+}
410+
411+func (d *browserDOM) syncPerformanceCustomInputStates() {
412+ if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() {
413+ return
414+ }
415+ forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]")
416+ for i := 0; i < forms.Get("length").Int(); i++ {
417+ updatePerformanceCustomInputs(forms.Index(i))
418+ }
419+}
420+
421+func updatePerformanceCustomInputs(form js.Value) {
422+ if !form.Truthy() || !form.Get("querySelector").Truthy() {
423+ return
424+ }
425+ selectEl := form.Call("querySelector", "[data-sv-performance-profile]")
426+ if !selectEl.Truthy() {
427+ return
428+ }
429+ enabled := strings.EqualFold(selectEl.Get("value").String(), "custom")
430+ inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]")
431+ for i := 0; i < inputs.Get("length").Int(); i++ {
432+ inputs.Index(i).Set("disabled", !enabled)
433+ }
434+}
435+
436+func collectFormData(form js.Value) map[string]string {
437+ data := map[string]string{}
438+ if !form.Truthy() || !js.Global().Get("FormData").Truthy() {
439+ return data
440+ }
441+ formData := js.Global().Get("FormData").New(form)
442+ entries := js.Global().Get("Array").Call("from", formData.Call("entries"))
443+ for i := 0; i < entries.Get("length").Int(); i++ {
444+ pair := entries.Index(i)
445+ if pair.Get("length").Int() < 2 {
446+ continue
447+ }
448+ key := pair.Index(0).String()
449+ value := pair.Index(1).String()
450+ if existing := data[key]; existing != "" {
451+ data[key] = existing + "," + value
452+ continue
453+ }
454+ data[key] = value
455+ }
456+ return data
457+}
458+
459+func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) {
460+ if onConfirm == nil {
461+ return
462+ }
463+ confirmAttr := element.Call("getAttribute", "data-sv-confirm")
464+ if confirmAttr.Type() != js.TypeString {
465+ onConfirm()
466+ return
467+ }
468+ message := strings.TrimSpace(confirmAttr.String())
469+ if message == "" {
470+ onConfirm()
471+ return
472+ }
473+ if !d.showConfirmDialog(message, onConfirm) {
474+ onConfirm()
475+ }
476+}
477+
478+func (d *browserDOM) cleanupActiveConfirmDialog() {
479+ if d == nil || d.activeConfirmCleanup == nil {
480+ return
481+ }
482+ cleanup := d.activeConfirmCleanup
483+ d.activeConfirmCleanup = nil
484+ cleanup()
485+}
486+
487+func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
488+ if d == nil || !d.document.Truthy() {
489+ return false
490+ }
491+ body := d.document.Get("body")
492+ if !body.Truthy() {
493+ return false
494+ }
495+ // Always close an existing modal through its cleanup path so key handlers and
496+ // js.Func values are released before the replacement is mounted.
497+ d.cleanupActiveConfirmDialog()
498+
499+ overlay := d.document.Call("createElement", "div")
500+ overlay.Get("classList").Call("add", "sv-confirm-backdrop")
501+ overlay.Set("role", "presentation")
502+
503+ dialog := d.document.Call("createElement", "div")
504+ dialog.Get("classList").Call("add", "sv-confirm-dialog")
505+ dialog.Set("role", "dialog")
506+ dialog.Call("setAttribute", "aria-modal", "true")
507+ dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title")
508+ dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message")
509+ overlay.Call("appendChild", dialog)
510+
511+ title := d.document.Call("createElement", "h3")
512+ title.Set("id", "sv-confirm-title")
513+ title.Set("textContent", "Confirm action")
514+ dialog.Call("appendChild", title)
515+
516+ text := d.document.Call("createElement", "p")
517+ text.Set("id", "sv-confirm-message")
518+ text.Set("textContent", message)
519+ dialog.Call("appendChild", text)
520+
521+ actions := d.document.Call("createElement", "div")
522+ actions.Get("classList").Call("add", "sv-confirm-actions")
523+ dialog.Call("appendChild", actions)
524+
525+ cancelButton := d.document.Call("createElement", "button")
526+ cancelButton.Set("type", "button")
527+ cancelButton.Set("className", "sv-button sv-button-secondary")
528+ cancelButton.Set("textContent", "Cancel")
529+ actions.Call("appendChild", cancelButton)
530+
531+ confirmButton := d.document.Call("createElement", "button")
532+ confirmButton.Set("type", "button")
533+ confirmButton.Set("className", "sv-button sv-button-danger")
534+ confirmButton.Set("textContent", "Confirm")
535+ actions.Call("appendChild", confirmButton)
536+
537+ d.activeConfirmID++
538+ confirmID := d.activeConfirmID
539+
540+ var cleanup func()
541+ var cancelFn js.Func
542+ var confirmFn js.Func
543+ var keyFn js.Func
544+ var backdropFn js.Func
545+ cleaned := false
546+ confirmed := false
547+ cleanup = func() {
548+ if cleaned {
549+ return
550+ }
551+ cleaned = true
552+ if d.activeConfirmID == confirmID {
553+ d.activeConfirmCleanup = nil
554+ }
555+ if overlay.Truthy() {
556+ overlay.Call("remove")
557+ }
558+ if d.document.Truthy() && keyFn.Value.Truthy() {
559+ d.document.Call("removeEventListener", "keydown", keyFn)
560+ }
561+ cancelFn.Release()
562+ confirmFn.Release()
563+ keyFn.Release()
564+ backdropFn.Release()
565+ }
566+ runConfirm := func() {
567+ if confirmed {
568+ return
569+ }
570+ confirmed = true
571+ cleanup()
572+ onConfirm()
573+ }
574+ cancelFn = js.FuncOf(func(js.Value, []js.Value) any {
575+ cleanup()
576+ return nil
577+ })
578+ confirmFn = js.FuncOf(func(js.Value, []js.Value) any {
579+ runConfirm()
580+ return nil
581+ })
582+ keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
583+ event := firstJSArg(args)
584+ if !event.Truthy() {
585+ return nil
586+ }
587+ switch strings.ToLower(event.Get("key").String()) {
588+ case "escape":
589+ event.Call("preventDefault")
590+ event.Call("stopPropagation")
591+ cleanup()
592+ case "enter":
593+ if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) {
594+ event.Call("preventDefault")
595+ event.Call("stopPropagation")
596+ runConfirm()
597+ }
598+ }
599+ return nil
600+ })
601+ backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
602+ event := firstJSArg(args)
603+ if event.Truthy() && event.Get("target").Equal(overlay) {
604+ cleanup()
605+ }
606+ return nil
607+ })
608+
609+ cancelButton.Call("addEventListener", "click", cancelFn)
610+ confirmButton.Call("addEventListener", "click", confirmFn)
611+ document := d.document
612+ document.Call("addEventListener", "keydown", keyFn)
613+ overlay.Call("addEventListener", "click", backdropFn)
614+ d.activeConfirmCleanup = cleanup
615+ body.Call("appendChild", overlay)
616+ confirmButton.Call("focus")
617+ return true
618+}
619+
620+func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool {
621+ if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() {
622+ return false
623+ }
624+ active := document.Get("activeElement")
625+ if !active.Truthy() {
626+ return true
627+ }
628+ if active.Equal(confirmButton) {
629+ return true
630+ }
631+ if dialog.Call("contains", active).Bool() {
632+ return false
633+ }
634+ return true
635+}
636+
637+func (d *browserDOM) handleKeyboard(event js.Value) {
638+ if d == nil || !d.target.Truthy() || !event.Truthy() {
639+ return
640+ }
641+ if d.isHistoryRoute() {
642+ d.handleHistoryKeyboard(event)
643+ return
644+ }
645+ if d.isFavoritesRoute() {
646+ d.handleFavoritesKeyboard(event)
647+ }
648+}
649+
650+func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
651+ if d == nil || !d.target.Truthy() || !event.Truthy() {
652+ return
653+ }
654+ key := strings.ToLower(event.Get("key").String())
655+ target := event.Get("target")
656+ if isEditableTarget(target) && key != "escape" {
657+ return
658+ }
659+
660+ switch key {
661+ case "/":
662+ if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() {
663+ event.Call("preventDefault")
664+ input.Call("focus")
665+ }
666+ case "j", "arrowdown":
667+ event.Call("preventDefault")
668+ d.focusHistoryRow(1)
669+ case "k", "arrowup":
670+ event.Call("preventDefault")
671+ d.focusHistoryRow(-1)
672+ case "enter":
673+ if row := d.activeHistoryRow(); row.Truthy() {
674+ event.Call("preventDefault")
675+ if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() {
676+ link.Call("click")
677+ }
678+ }
679+ case "d", "delete", "backspace":
680+ // Delete the focused row through the same delegated click path as pointer users,
681+ // including the data-sv-confirm guard on the delete button.
682+ if row := d.activeHistoryRow(); row.Truthy() {
683+ event.Call("preventDefault")
684+ if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
685+ button.Call("click")
686+ }
687+ }
688+ case "escape":
689+ if target.Truthy() && isEditableTarget(target) {
690+ target.Call("blur")
691+ }
692+ }
693+}
694+
695+func (d *browserDOM) isHistoryRoute() bool {
696+ return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
697+}
698+
699+func (d *browserDOM) isFavoritesRoute() bool {
700+ return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy()
701+}
702+
703+func (d *browserDOM) handleFavoritesKeyboard(event js.Value) {
704+ key := strings.ToLower(event.Get("key").String())
705+ target := event.Get("target")
706+ if isEditableTarget(target) && key != "escape" {
707+ return
708+ }
709+
710+ switch key {
711+ case "j", "arrowdown":
712+ event.Call("preventDefault")
713+ d.focusFavoriteRow(1)
714+ case "k", "arrowup":
715+ event.Call("preventDefault")
716+ d.focusFavoriteRow(-1)
717+ case "enter":
718+ if row := d.activeFavoriteRow(); row.Truthy() {
719+ event.Call("preventDefault")
720+ if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() {
721+ link.Call("click")
722+ }
723+ }
724+ case "e":
725+ if row := d.activeFavoriteRow(); row.Truthy() {
726+ event.Call("preventDefault")
727+ if input := row.Call("querySelector", `[name="title"]`); input.Truthy() {
728+ input.Call("focus")
729+ }
730+ }
731+ case "d", "delete", "backspace":
732+ if row := d.activeFavoriteRow(); row.Truthy() {
733+ event.Call("preventDefault")
734+ if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() {
735+ button.Call("click")
736+ }
737+ }
738+ case "escape":
739+ if target.Truthy() && isEditableTarget(target) {
740+ target.Call("blur")
741+ }
742+ }
743+}
744+
745+func isEditableTarget(target js.Value) bool {
746+ if !target.Truthy() {
747+ return false
748+ }
749+ tag := strings.ToLower(target.Get("tagName").String())
750+ role := strings.ToLower(target.Get("role").String())
751+ return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" ||
752+ (tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" ||
753+ target.Get("isContentEditable").Bool()
754+}
755+
756+func (d *browserDOM) focusHistoryRow(delta int) {
757+ d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta)
758+}
759+
760+func (d *browserDOM) activeHistoryRow() js.Value {
761+ return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex")
762+}
763+
764+func (d *browserDOM) focusFavoriteRow(delta int) {
765+ d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta)
766+}
767+
768+func (d *browserDOM) activeFavoriteRow() js.Value {
769+ return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex")
770+}
771+
772+func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
773+ rows := d.target.Call("querySelectorAll", selector)
774+ length := rows.Get("length").Int()
775+ if length == 0 {
776+ return
777+ }
778+ idx := d.activeIndex(datasetKey)
779+ if idx < 0 {
780+ idx = 0
781+ } else {
782+ idx += delta
783+ }
784+ if idx < 0 {
785+ idx = 0
786+ }
787+ if idx >= length {
788+ idx = length - 1
789+ }
790+ for i := 0; i < length; i++ {
791+ rows.Index(i).Get("classList").Call("remove", "sv-focused")
792+ }
793+ row := rows.Index(idx)
794+ row.Get("classList").Call("add", "sv-focused")
795+ d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx))
796+ row.Call("scrollIntoView", map[string]any{"block": "nearest"})
797+}
798+
799+func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
800+ rows := d.target.Call("querySelectorAll", selector)
801+ length := rows.Get("length").Int()
802+ if length <= 0 {
803+ return js.Value{}
804+ }
805+ idx := d.activeIndex(datasetKey)
806+ if idx < 0 || idx >= length {
807+ return js.Value{}
808+ }
809+ return rows.Index(idx)
810+}
811+
812+func (d *browserDOM) activeIndex(datasetKey string) int {
813+ value := d.target.Get("dataset").Get(datasetKey).String()
814+ idx, err := strconv.Atoi(value)
815+ if err != nil {
816+ return -1
817+ }
818+ return idx
819+}
A internal/ui/systemviews/favorite_actions.go +311 -0
@@ -0,0 +1,311 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/url"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+const (
15+ favoriteActionCreate = "favorite.create"
16+ favoriteActionUpdate = "favorite.update"
17+ favoriteActionDelete = "favorite.delete"
18+ favoriteActionFilterFolder = "favorite.filterFolder"
19+ favoriteActionFilterTag = "favorite.filterTag"
20+ favoriteActionClearFilters = "favorite.clearFilters"
21+ folderActionCreate = "folder.create"
22+ folderActionUpdate = "folder.update"
23+ folderActionDelete = "folder.delete"
24+ tagActionCreate = "tag.create"
25+ tagActionUpdate = "tag.update"
26+ tagActionDelete = "tag.delete"
27+ tagActionAssign = "tag.assign"
28+ tagActionRemove = "tag.remove"
29+)
30+
31+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
32+func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
33+ if a.deps.Favorites == nil {
34+ return fmt.Errorf("favorites service not configured")
35+ }
36+ data := event.Data
37+ switch event.Action {
38+ case favoriteActionCreate:
39+ folderID, err := optionalFolderID(data["folder_id"])
40+ if err != nil {
41+ return err
42+ }
43+ favoriteURL, err := validateFavoriteURL(data["url"])
44+ if err != nil {
45+ return err
46+ }
47+ tags, err := optionalTagIDs(data["tags"])
48+ if err != nil {
49+ return err
50+ }
51+ favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
52+ URL: favoriteURL,
53+ Title: strings.TrimSpace(data["title"]),
54+ FolderID: folderID,
55+ Tags: tags,
56+ })
57+ if err != nil {
58+ return err
59+ }
60+ a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
61+ case favoriteActionUpdate:
62+ id, err := parsePositiveInt64(data["id"], "favorite id")
63+ if err != nil {
64+ return err
65+ }
66+ folderID, err := optionalFolderID(data["folder_id"])
67+ if err != nil {
68+ return err
69+ }
70+ shortcut, err := optionalShortcut(data["shortcut_key"])
71+ if err != nil {
72+ return err
73+ }
74+ favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
75+ ID: entity.FavoriteID(id),
76+ Title: strings.TrimSpace(data["title"]),
77+ FaviconURL: strings.TrimSpace(data["favicon_url"]),
78+ FolderID: folderID,
79+ ShortcutKey: shortcut,
80+ })
81+ if err != nil {
82+ return err
83+ }
84+ a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
85+ case favoriteActionDelete:
86+ id, err := parsePositiveInt64(data["id"], "favorite id")
87+ if err != nil {
88+ return err
89+ }
90+ if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil {
91+ return err
92+ }
93+ a.favoritesNotice = "Deleted favorite"
94+ case favoriteActionFilterFolder:
95+ folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
96+ if err != nil {
97+ return err
98+ }
99+ a.favoriteFolderFilter = folderID
100+ a.favoriteTagFilter = nil
101+ a.favoritesNotice = ""
102+ case favoriteActionFilterTag:
103+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
104+ if err != nil {
105+ return err
106+ }
107+ id := entity.TagID(tagID)
108+ a.favoriteTagFilter = &id
109+ a.favoriteFolderFilter = nil
110+ a.favoritesNotice = ""
111+ case favoriteActionClearFilters:
112+ a.favoriteFolderFilter = nil
113+ a.favoriteTagFilter = nil
114+ a.favoritesNotice = ""
115+ case folderActionCreate:
116+ name := strings.TrimSpace(data["name"])
117+ if name == "" {
118+ return fmt.Errorf("folder name is required")
119+ }
120+ folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
121+ if err != nil {
122+ return err
123+ }
124+ a.favoritesNotice = "Created folder " + folderDisplayName(folder)
125+ case folderActionUpdate:
126+ id, err := parsePositiveInt64(data["id"], "folder id")
127+ if err != nil {
128+ return err
129+ }
130+ name := strings.TrimSpace(data["name"])
131+ if name == "" {
132+ return fmt.Errorf("folder name is required")
133+ }
134+ if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil {
135+ return err
136+ }
137+ a.favoritesNotice = "Saved folder " + name
138+ case folderActionDelete:
139+ id, err := parsePositiveInt64(data["id"], "folder id")
140+ if err != nil {
141+ return err
142+ }
143+ if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil {
144+ return err
145+ }
146+ a.favoriteFolderFilter = nil
147+ a.favoritesNotice = "Deleted folder"
148+ case tagActionCreate:
149+ name := strings.TrimSpace(data["name"])
150+ if name == "" {
151+ return fmt.Errorf("tag name is required")
152+ }
153+ tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"]))
154+ if err != nil {
155+ return err
156+ }
157+ a.favoritesNotice = "Created tag " + tag.Name
158+ case tagActionUpdate:
159+ id, err := parsePositiveInt64(data["id"], "tag id")
160+ if err != nil {
161+ return err
162+ }
163+ name := strings.TrimSpace(data["name"])
164+ if name == "" {
165+ return fmt.Errorf("tag name is required")
166+ }
167+ if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil {
168+ return err
169+ }
170+ a.favoritesNotice = "Saved tag " + name
171+ case tagActionDelete:
172+ id, err := parsePositiveInt64(data["id"], "tag id")
173+ if err != nil {
174+ return err
175+ }
176+ if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil {
177+ return err
178+ }
179+ a.favoriteTagFilter = nil
180+ a.favoritesNotice = "Deleted tag"
181+ case tagActionAssign, tagActionRemove:
182+ favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
183+ if err != nil {
184+ return err
185+ }
186+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
187+ if err != nil {
188+ return err
189+ }
190+ if event.Action == tagActionAssign {
191+ if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil {
192+ return err
193+ }
194+ a.favoritesNotice = "Assigned tag"
195+ } else {
196+ if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil {
197+ return err
198+ }
199+ a.favoritesNotice = "Removed tag"
200+ }
201+ default:
202+ return fmt.Errorf("unknown favorite action: %q", event.Action)
203+ }
204+ return nil
205+}
206+
207+func validateFavoriteURL(raw string) (string, error) {
208+ favoriteURL := strings.TrimSpace(raw)
209+ if favoriteURL == "" {
210+ return "", fmt.Errorf("favorite URL is required")
211+ }
212+ parsed, err := url.Parse(favoriteURL)
213+ if err != nil || parsed.Scheme == "" {
214+ return "", fmt.Errorf("favorite URL must be absolute")
215+ }
216+ switch strings.ToLower(parsed.Scheme) {
217+ case "http", "https":
218+ if parsed.Host == "" {
219+ return "", fmt.Errorf("favorite URL host is required")
220+ }
221+ case "dumb":
222+ if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
223+ return favoriteURL, nil
224+ }
225+ return "", fmt.Errorf("favorite URL must target a known systemview route")
226+ default:
227+ return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
228+ }
229+ return favoriteURL, nil
230+}
231+
232+func firstActionValue(data map[string]string, keys ...string) string {
233+ for _, key := range keys {
234+ if value, ok := data[key]; ok {
235+ return value
236+ }
237+ }
238+ return ""
239+}
240+
241+func parsePositiveInt64(raw, label string) (int64, error) {
242+ id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
243+ if err != nil {
244+ return 0, fmt.Errorf("invalid %s: %w", label, err)
245+ }
246+ if id <= 0 {
247+ return 0, fmt.Errorf("%s must be a positive integer", label)
248+ }
249+ return id, nil
250+}
251+
252+func optionalTagIDs(raw string) ([]entity.TagID, error) {
253+ raw = strings.TrimSpace(raw)
254+ if raw == "" {
255+ return nil, nil
256+ }
257+ parts := strings.Split(raw, ",")
258+ ids := make([]entity.TagID, 0, len(parts))
259+ seen := make(map[entity.TagID]struct{}, len(parts))
260+ for _, part := range parts {
261+ id, err := parsePositiveInt64(part, "tag id")
262+ if err != nil {
263+ return nil, err
264+ }
265+ tagID := entity.TagID(id)
266+ if _, ok := seen[tagID]; ok {
267+ continue
268+ }
269+ seen[tagID] = struct{}{}
270+ ids = append(ids, tagID)
271+ }
272+ return ids, nil
273+}
274+
275+// optionalFolderID returns nil for empty or "root" input, meaning no folder constraint;
276+// otherwise it parses a positive folder id into a non-nil *entity.FolderID.
277+func optionalFolderID(raw string) (*entity.FolderID, error) {
278+ raw = strings.TrimSpace(raw)
279+ if raw == "" || raw == "root" {
280+ return nil, nil
281+ }
282+ id, err := parsePositiveInt64(raw, "folder id")
283+ if err != nil {
284+ return nil, err
285+ }
286+ folderID := entity.FolderID(id)
287+ return &folderID, nil
288+}
289+
290+// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
291+// favorites in the root folder; other inputs delegate to optionalFolderID.
292+func filterFolderID(raw string) (*entity.FolderID, error) {
293+ if strings.TrimSpace(raw) == "root" {
294+ root := entity.FolderID(0)
295+ return &root, nil
296+ }
297+ return optionalFolderID(raw)
298+}
299+
300+// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
301+func optionalShortcut(raw string) (*int, error) {
302+ raw = strings.TrimSpace(raw)
303+ if raw == "" {
304+ return nil, nil
305+ }
306+ value, err := strconv.Atoi(raw)
307+ if err != nil || value < 1 || value > 9 {
308+ return nil, fmt.Errorf("shortcut key must be 1-9")
309+ }
310+ return &value, nil
311+}
A internal/ui/systemviews/favorites.templ +218 -0
@@ -0,0 +1,218 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/dumber/internal/domain/entity"
6+)
7+
8+templ FavoritesView(data favoritesRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-favorites-controls", "Manage favorites") {
12+ @FavoritesControls(data)
13+ }
14+ @Section("sv-favorites-list", "Favorites") {
15+ @Meta(favoritesSummary(data))
16+ if len(data.Favorites) == 0 {
17+ @EmptyState("No favorites")
18+ } else {
19+ <ul class="sv-list">
20+ for _, favorite := range data.Favorites {
21+ if favorite != nil {
22+ @FavoriteItem(favorite, data.Folders, data.Tags)
23+ }
24+ }
25+ </ul>
26+ }
27+ }
28+ @Section("sv-folders-list", "Folders") {
29+ @Meta(foldersSummary(data))
30+ @FolderCreateForm()
31+ if len(data.Folders) == 0 {
32+ @EmptyState("No folders")
33+ } else {
34+ <ul class="sv-list">
35+ for _, folder := range data.Folders {
36+ if folder != nil {
37+ @FolderItem(folder)
38+ }
39+ }
40+ </ul>
41+ }
42+ }
43+ @Section("sv-tags-list", "Tags") {
44+ @Meta(tagsSummary(data))
45+ @TagCreateForm()
46+ if len(data.Tags) == 0 {
47+ @EmptyState("No tags")
48+ } else {
49+ <ul class="sv-list">
50+ for _, tag := range data.Tags {
51+ if tag != nil {
52+ @TagItem(tag)
53+ }
54+ }
55+ </ul>
56+ }
57+ }
58+}
59+
60+templ FavoritesControls(data favoritesRenderData) {
61+ <form class="sv-card-form" data-sv-action="favorite.create">
62+ <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
63+ <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64+ @FolderSelect("folder_id", data.Folders, nil, "Folder")
65+ @FavoriteCreateTagPicker(data.Tags)
66+ <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button>
67+ </form>
68+ <div class="sv-filter-panel">
69+ <div class="sv-button-row">
70+ <span class="sv-meta">Folders</span>
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>
73+ for _, folder := range data.Folders {
74+ if folder != nil {
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>
76+ }
77+ }
78+ </div>
79+ <div class="sv-button-row">
80+ <span class="sv-meta">Tags</span>
81+ for _, tag := range data.Tags {
82+ if tag != nil {
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) }>
84+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }
85+ </button>
86+ }
87+ }
88+ </div>
89+ </div>
90+ <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>
91+}
92+
93+templ FavoriteCreateTagPicker(tags []*entity.Tag) {
94+ if len(tags) > 0 {
95+ <fieldset class="sv-tag-picker">
96+ <legend>Tags</legend>
97+ <div class="sv-tag-controls">
98+ for _, tag := range tags {
99+ if tag != nil {
100+ <label class="sv-tag-toggle">
101+ <input type="checkbox" name="tags" value={ fmt.Sprintf("%d", tag.ID) }/>
102+ <span><span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }</span>
103+ </label>
104+ }
105+ }
106+ </div>
107+ </fieldset>
108+ }
109+}
110+
111+templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
112+ <li class="sv-list-row">
113+ <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
114+ <div class="sv-favorite-header">
115+ <div>
116+ <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>
117+ <p class="sv-history-url">{ favorite.URL }</p>
118+ if favoriteMetaText(favorite, folders) != "" {
119+ <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p>
120+ }
121+ </div>
122+ <div class="sv-history-row-actions">
123+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a>
124+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button>
125+ </div>
126+ </div>
127+ @FavoriteEditForm(favorite, folders)
128+ @FavoriteTagControls(favorite, tags)
129+ </article>
130+ </li>
131+}
132+
133+templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {
134+ <form class="sv-inline-form" data-sv-action="favorite.update">
135+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>
136+ <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>
137+ <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
138+ @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
139+ @ShortcutSelect(favorite.ShortcutKey)
140+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
141+ </form>
142+}
143+
144+templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
145+ if len(tags) > 0 {
146+ <div class="sv-tag-controls">
147+ <span class="sv-meta">Tags</span>
148+ for _, tag := range tags {
149+ if tag != nil {
150+ <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
151+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) }
152+ </button>
153+ }
154+ }
155+ </div>
156+ }
157+}
158+
159+templ FolderCreateForm() {
160+ <form class="sv-inline-form" data-sv-action="folder.create">
161+ <label><span>Name</span><input name="name" type="text" required/></label>
162+ <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label>
163+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button>
164+ </form>
165+}
166+
167+templ FolderItem(folder *entity.Folder) {
168+ <li class="sv-list-row">
169+ <form class="sv-inline-form" data-sv-action="folder.update">
170+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/>
171+ <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label>
172+ <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label>
173+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
174+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button>
175+ </form>
176+ </li>
177+}
178+
179+templ TagCreateForm() {
180+ <form class="sv-inline-form" data-sv-action="tag.create">
181+ <label><span>Name</span><input name="name" type="text" required/></label>
182+ <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label>
183+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconTag, "Create tag")</button>
184+ </form>
185+}
186+
187+templ TagItem(tag *entity.Tag) {
188+ <li class="sv-list-row">
189+ <form class="sv-inline-form" data-sv-action="tag.update">
190+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>
191+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>
192+ <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
193+ <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>
194+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
195+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?" aria-label="Delete this tag">@IconOnly(iconTrash, "Delete this tag")</button>
196+ </form>
197+ </li>
198+}
199+
200+templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) {
201+ <label><span>{ label }</span><select name={ name }>
202+ <option value="">Unfiled</option>
203+ for _, folder := range folders {
204+ if folder != nil {
205+ <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option>
206+ }
207+ }
208+ </select></label>
209+}
210+
211+templ ShortcutSelect(selected *int) {
212+ <label><span>Shortcut</span><select name="shortcut_key">
213+ <option value="">None</option>
214+ for i := 1; i <= 9; i++ {
215+ <option value={ fmt.Sprintf("%d", i) } selected?={ shortcutSelected(selected, i) }>{ fmt.Sprintf("%d", i) }</option>
216+ }
217+ </select></label>
218+}
A internal/ui/systemviews/favorites_templ.go +1416 -0 generated file: collapsed
A internal/ui/systemviews/favorites_view.go +193 -0
@@ -0,0 +1,193 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+const defaultTagColor = "#808080"
11+
12+type favoritesRenderData struct {
13+ Favorites []*entity.Favorite
14+ Folders []*entity.Folder
15+ Tags []*entity.Tag
16+ FolderFilter *entity.FolderID
17+ TagFilter *entity.TagID
18+ Notice string
19+ Error string
20+}
21+
22+func favoritesHTML(data favoritesRenderData) string {
23+ return mustRenderComponent(FavoritesView(data))
24+}
25+
26+func favoritesDocumentTitle(data favoritesRenderData) string {
27+ count := len(data.Favorites)
28+ if data.FolderFilter != nil || data.TagFilter != nil {
29+ if count == 1 {
30+ return "Favorites — 1 filtered bookmark"
31+ }
32+ return fmt.Sprintf("Favorites — %d filtered bookmarks", count)
33+ }
34+ if count == 1 {
35+ return "Favorites — 1 bookmark"
36+ }
37+ return fmt.Sprintf("Favorites — %d bookmarks", count)
38+}
39+
40+func favoritesSummary(data favoritesRenderData) string {
41+ return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
42+}
43+
44+func foldersSummary(data favoritesRenderData) string {
45+ return fmt.Sprintf("%d folders", len(data.Folders))
46+}
47+
48+func tagsSummary(data favoritesRenderData) string {
49+ return fmt.Sprintf("%d tags", len(data.Tags))
50+}
51+
52+func favoriteFilterClass(active bool) string {
53+ classes := "sv-button sv-button-secondary"
54+ if active {
55+ classes += " sv-button-active"
56+ }
57+ return classes
58+}
59+
60+func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool {
61+ return data.FolderFilter != nil && *data.FolderFilter == folderID
62+}
63+
64+func favoriteAllFilterActive(data favoritesRenderData) bool {
65+ return data.FolderFilter == nil && data.TagFilter == nil
66+}
67+
68+func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {
69+ return data.TagFilter != nil && *data.TagFilter == tagID
70+}
71+
72+func favoriteItemLabel(favorite *entity.Favorite) string {
73+ if favorite == nil {
74+ return ""
75+ }
76+ label := strings.TrimSpace(favorite.Title)
77+ if label == "" {
78+ label = favorite.URL
79+ }
80+ return label
81+}
82+
83+func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string {
84+ if favorite == nil {
85+ return ""
86+ }
87+ parts := make([]string, 0, 2)
88+ if favorite.ShortcutKey != nil {
89+ parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
90+ }
91+ if name := folderNameForID(folders, favorite.FolderID); name != "" {
92+ parts = append(parts, "Folder "+name)
93+ }
94+ return strings.Join(parts, " · ")
95+}
96+
97+func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {
98+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
99+ return tagActionRemove
100+ }
101+ return tagActionAssign
102+}
103+
104+func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string {
105+ if tag == nil {
106+ return ""
107+ }
108+ if favorite != nil && favorite.HasTag(tag.ID) {
109+ return "✓ " + tag.Name
110+ }
111+ return "+ " + tag.Name
112+}
113+
114+func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
115+ classes := "sv-button sv-button-secondary sv-tag-button"
116+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
117+ classes += " sv-button-active sv-tag-button-active"
118+ }
119+ return classes
120+}
121+
122+func safeTagColor(raw string) string {
123+ value := strings.TrimSpace(raw)
124+ if len(value) != 4 && len(value) != 7 {
125+ return defaultTagColor
126+ }
127+ if value[0] != '#' {
128+ return defaultTagColor
129+ }
130+ for _, ch := range value[1:] {
131+ if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
132+ continue
133+ }
134+ return defaultTagColor
135+ }
136+ return value
137+}
138+
139+func folderDisplayName(folder *entity.Folder) string {
140+ if folder == nil {
141+ return ""
142+ }
143+ if strings.TrimSpace(folder.Icon) != "" {
144+ return folder.Icon + " " + folder.Name
145+ }
146+ return folder.Name
147+}
148+
149+func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string {
150+ if id == nil {
151+ return "Unfiled"
152+ }
153+ for _, folder := range folders {
154+ if folder != nil && folder.ID == *id {
155+ return folderDisplayName(folder)
156+ }
157+ }
158+ return "Unknown"
159+}
160+
161+func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool {
162+ return selected != nil && *selected == folderID
163+}
164+
165+func shortcutSelected(selected *int, value int) bool {
166+ return selected != nil && *selected == value
167+}
168+
169+func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {
170+ if folderID == nil && tagID == nil {
171+ return favorites
172+ }
173+ out := make([]*entity.Favorite, 0, len(favorites))
174+ for _, favorite := range favorites {
175+ if favorite == nil {
176+ continue
177+ }
178+ if folderID != nil {
179+ if *folderID == 0 {
180+ if favorite.FolderID != nil {
181+ continue
182+ }
183+ } else if favorite.FolderID == nil || *favorite.FolderID != *folderID {
184+ continue
185+ }
186+ }
187+ if tagID != nil && !favorite.HasTag(*tagID) {
188+ continue
189+ }
190+ out = append(out, favorite)
191+ }
192+ return out
193+}
A internal/ui/systemviews/history.templ +172 -0
@@ -0,0 +1,172 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+templ HistoryView(data historyRenderData) {
10+ @Alert("success", data.Notice)
11+ @Alert("error", data.Error)
12+ @Section("sv-history-controls", "Manage history") {
13+ @HistoryControls(data)
14+ }
15+ @Section("sv-history-domains", "Top domains") {
16+ @HistoryDomains(data)
17+ }
18+ @Section("sv-history-timeline", "Recent visits") {
19+ @HistoryTimeline(data)
20+ }
21+}
22+
23+templ HistoryControls(data historyRenderData) {
24+ <form class="sv-toolbar sv-history-search" data-sv-action="history.search">
25+ <label class="sv-search-label">
26+ <span>Search</span>
27+ <input data-sv-history-search data-sv-autofocus name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off" autofocus/>
28+ </label>
29+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSearch, "Search")</button>
30+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">@ButtonIconLabel(iconX, "Clear")</button>
31+ </form>
32+ if showHistoryFilters(data) {
33+ <div class="sv-active-filters">
34+ if data.Query != "" {
35+ <span class="sv-filter-chip">Query: { data.Query }</span>
36+ }
37+ if data.DomainFilter != "" {
38+ <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">@IconOnly(iconX, "Clear domain filter")</button></span>
39+ }
40+ </div>
41+ }
42+ <div class="sv-history-actions-panel">
43+ <div class="sv-button-row" aria-label="History pagination">
44+ <span class="sv-meta">{ historyShowingLabel(data) }</span>
45+ </div>
46+ <div class="sv-button-row" aria-label="History cleanup">
47+ <span class="sv-meta">Clean up</span>
48+ for _, item := range historyCleanupItems() {
49+ <button type="button" class="sv-danger-link" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button>
50+ }
51+ </div>
52+ </div>
53+}
54+
55+templ HistoryDomains(data historyRenderData) {
56+ if data.Loading {
57+ @LoadingPanel("Loading top domains…")
58+ } else if len(data.Domains) == 0 {
59+ @EmptyState("No domain statistics yet")
60+ } else {
61+ <div class="sv-domain-list">
62+ for _, domain := range data.Domains {
63+ if domain != nil && domain.Domain != "" {
64+ {{ domainKey := historyDomainActionKey(domain) }}
65+ {{ displayDomain := historyDomainDisplayLabel(domain) }}
66+ <span class="sv-domain-chip">
67+ <span class="sv-domain-chip-main">
68+ <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button>
69+ <span class="sv-domain-meta">{ fmt.Sprintf("%dp · %dv", domain.PageCount, domain.TotalVisits) }</span>
70+ </span>
71+ <button type="button" class="sv-domain-delete" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }><span aria-hidden="true">×</span><span class="sv-sr-only">Delete history for { displayDomain }</span></button>
72+ </span>
73+ }
74+ }
75+ </div>
76+ }
77+}
78+
79+templ HistoryTimeline(data historyRenderData) {
80+ if data.Loading {
81+ @LoadingPanel("Loading recent visits…")
82+ } else {
83+ <div class="sv-history-timeline-list" data-sv-history-timeline>
84+ {{ groups := groupHistoryEntries(data.Entries) }}
85+ if len(groups) == 0 {
86+ @EmptyState(historyTimelineEmptyMessage(data))
87+ } else {
88+ @HistoryTimelineGroups(data.Entries, "")
89+ }
90+ @HistoryLoadMore(data)
91+ </div>
92+ }
93+}
94+
95+templ HistoryTimelineAppend(data historyRenderData) {
96+ @HistoryTimelineAppendGroups(data.Entries, data.AppendSkipFirstDate)
97+ @HistoryLoadMore(data)
98+}
99+
100+templ HistoryTimelineGroups(entries []*entity.HistoryEntry, skipFirstDate string) {
101+ {{ groups := groupHistoryEntries(entries) }}
102+ for _, group := range groups {
103+ @HistoryTimelineGroup(group, true)
104+ }
105+}
106+
107+templ HistoryTimelineAppendGroups(entries []*entity.HistoryEntry, mergeFirstDate string) {
108+ {{ groups := groupHistoryEntries(entries) }}
109+ for index, group := range groups {
110+ if index == 0 && group.Date == mergeFirstDate {
111+ <div data-sv-history-merge-date={ group.Date }>
112+ for _, entry := range group.Entries {
113+ if entry != nil {
114+ @HistoryItem(entry)
115+ }
116+ }
117+ </div>
118+ } else {
119+ @HistoryTimelineGroup(group, true)
120+ }
121+ }
122+}
123+
124+templ HistoryTimelineGroup(group historyTimelineGroup, showHeading bool) {
125+ <section class="sv-timeline-group" data-sv-history-date={ group.Date }>
126+ if showHeading {
127+ <h3>{ group.Label }</h3>
128+ }
129+ <ul class="sv-list">
130+ for _, entry := range group.Entries {
131+ if entry != nil {
132+ @HistoryItem(entry)
133+ }
134+ }
135+ </ul>
136+ </section>
137+}
138+
139+templ HistoryLoadMore(data historyRenderData) {
140+ <div class="sv-history-load-more" data-sv-history-load-more-container>
141+ if showHistoryLoadMore(data) {
142+ <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.loadMore" data-before={ historyLoadMoreCursor(data) } data-sv-history-load-more>Load older visits</button>
143+ } else if strings.TrimSpace(data.Query) == "" && countHistoryEntries(data.Entries) > 0 {
144+ <p class="sv-meta">End of history</p>
145+ }
146+ </div>
147+}
148+
149+templ HistoryItem(entry *entity.HistoryEntry) {
150+ <li class="sv-list-row">
151+ <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }>
152+ <div class="sv-history-content">
153+ {{ faviconURL := historyItemFaviconURL(entry) }}
154+ <span class={ historyFaviconClass(faviconURL) } aria-hidden="true">
155+ <span class="sv-history-favicon-fallback">{ historyFaviconFallback(entry) }</span>
156+ if faviconURL != "" {
157+ <img src={ templ.SafeURL(faviconURL) } alt="" width="24" height="24" loading="lazy" decoding="async" referrerpolicy="same-origin" onerror="this.parentElement.classList.remove('sv-history-favicon-has-image'); this.remove()"/>
158+ }
159+ </span>
160+ <div class="sv-history-main">
161+ <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a>
162+ <p class="sv-history-url" title={ entry.URL }>{ historyItemURL(entry) }</p>
163+ <p class="sv-history-meta">{ historyItemMeta(entry) }</p>
164+ </div>
165+ </div>
166+ <div class="sv-history-row-actions">
167+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a>
168+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" aria-label="Delete this history entry" disabled?={ entry.ID <= 0 }>@IconOnly(iconTrash, "Delete this history entry")</button>
169+ </div>
170+ </article>
171+ </li>
172+}
A internal/ui/systemviews/history_actions.go +306 -0
@@ -0,0 +1,306 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+const (
12+ historyActionSearch = "history.search"
13+ historyActionClear = "history.clear"
14+ historyActionFilterDomain = "history.filterDomain"
15+ historyActionClearDomain = "history.clearDomain"
16+ historyActionPage = "history.page"
17+ historyActionLoadMore = "history.loadMore"
18+ historyActionDeleteEntry = "history.deleteEntry"
19+ historyActionDeleteRange = "history.deleteRange"
20+ historyActionDeleteDomain = "history.deleteDomain"
21+)
22+
23+// HandleDOMAction applies an action delegated from the browser DOM and refreshes
24+// the mounted view. It keeps UI orchestration in the systemview edge and calls
25+// only application ports for data changes.
26+func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
27+ if a == nil {
28+ return fmt.Errorf("app is nil")
29+ }
30+ a.lockState()
31+ if a.closed {
32+ a.unlockState()
33+ return nil
34+ }
35+ a.renderGeneration++
36+ if a.currentRoute == RouteUnknown || a.currentRoute == "" {
37+ a.currentRoute = ParseRoute(a.deps.LocationURI)
38+ }
39+ route := a.currentRoute
40+ if route == RouteHistory && event.Action == historyActionLoadMore {
41+ a.unlockState()
42+ return a.handleHistoryLoadMore(ctx, event)
43+ }
44+ defer a.unlockState()
45+
46+ switch route {
47+ case RouteHistory:
48+ a.historyError = ""
49+ if err := a.handleHistoryAction(ctx, event); err != nil {
50+ a.historyNotice = ""
51+ a.historyError = err.Error()
52+ }
53+ if err := a.loadHistoryRoute(ctx); err != nil {
54+ a.renderRouteError(err)
55+ _ = a.mountRenderedHTML()
56+ return err
57+ }
58+ return a.mountRenderedHTML()
59+ case RouteFavorites:
60+ a.favoritesError = ""
61+ if err := a.handleFavoriteAction(ctx, event); err != nil {
62+ a.favoritesNotice = ""
63+ a.favoritesError = err.Error()
64+ }
65+ if err := a.loadFavoritesRoute(ctx); err != nil {
66+ a.renderRouteError(err)
67+ _ = a.mountRenderedHTML()
68+ return err
69+ }
70+ return a.mountRenderedHTML()
71+ case RouteConfig:
72+ a.configError = ""
73+ if err := a.handleConfigAction(ctx, event); err != nil {
74+ a.configNotice = ""
75+ a.configError = err.Error()
76+ }
77+ a.loadShellTheme(ctx)
78+ if err := a.loadConfigRoute(ctx); err != nil {
79+ a.renderRouteError(err)
80+ _ = a.mountRenderedHTML()
81+ return err
82+ }
83+ return a.mountRenderedHTML()
84+ default:
85+ return nil
86+ }
87+}
88+
89+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
90+func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
91+ if a.deps.History == nil {
92+ return fmt.Errorf("history service not configured")
93+ }
94+
95+ data := event.Data
96+ switch event.Action {
97+ case historyActionSearch:
98+ a.historyQuery = strings.TrimSpace(data["query"])
99+ a.historyDomainFilter = ""
100+ a.resetHistoryWindowState()
101+ a.historyNotice = ""
102+ case historyActionClear:
103+ a.historyQuery = ""
104+ a.historyDomainFilter = ""
105+ a.resetHistoryWindowState()
106+ a.historyNotice = ""
107+ case historyActionFilterDomain:
108+ domain := strings.TrimSpace(data["domain"])
109+ if domain == "" {
110+ return fmt.Errorf("domain is required")
111+ }
112+ a.historyDomainFilter = domain
113+ a.historyQuery = ""
114+ a.resetHistoryWindowState()
115+ a.historyNotice = ""
116+ case historyActionClearDomain:
117+ a.historyDomainFilter = ""
118+ a.resetHistoryWindowState()
119+ a.historyNotice = ""
120+ case historyActionPage:
121+ offset, err := strconv.Atoi(strings.TrimSpace(data["offset"]))
122+ if err != nil || offset < 0 {
123+ return fmt.Errorf("invalid history offset")
124+ }
125+ a.resetHistoryWindowState()
126+ a.historyOffset = offset
127+ a.historyNotice = ""
128+ case historyActionDeleteEntry:
129+ id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64)
130+ if err != nil || id <= 0 {
131+ return fmt.Errorf("invalid history entry id")
132+ }
133+ if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
134+ return err
135+ }
136+ a.resetHistoryWindowState()
137+ a.historyNotice = "Deleted history entry"
138+ case historyActionDeleteRange:
139+ rangeID := strings.TrimSpace(data["range"])
140+ if rangeID == "" {
141+ return fmt.Errorf("history range is required")
142+ }
143+ if !isKnownHistoryCleanupRange(rangeID) {
144+ return fmt.Errorf("invalid history range")
145+ }
146+ if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
147+ return err
148+ }
149+ a.resetHistoryWindowState()
150+ a.historyNotice = historyRangeNotice(rangeID)
151+ case historyActionDeleteDomain:
152+ domain := strings.TrimSpace(data["domain"])
153+ if domain == "" {
154+ return fmt.Errorf("domain is required")
155+ }
156+ if err := a.deps.History.DeleteDomain(ctx, domain); err != nil {
157+ return err
158+ }
159+ if strings.EqualFold(a.historyDomainFilter, domain) {
160+ a.historyDomainFilter = ""
161+ }
162+ a.resetHistoryWindowState()
163+ a.historyNotice = "Deleted history for " + domain
164+ default:
165+ return fmt.Errorf("unknown history action: %q", event.Action)
166+ }
167+ return nil
168+}
169+
170+func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {
171+ a.lockState()
172+ if a.deps.History == nil {
173+ a.unlockState()
174+ return fmt.Errorf("history service not configured")
175+ }
176+ if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
177+ a.unlockState()
178+ return nil
179+ }
180+ before := a.historyWindowAfter
181+ if requested := strings.TrimSpace(event.Data["before"]); requested != "" {
182+ parsed, err := time.Parse(time.RFC3339Nano, requested)
183+ if err != nil {
184+ a.unlockState()
185+ return fmt.Errorf("invalid history cursor")
186+ }
187+ if !before.IsZero() && !parsed.Equal(before) {
188+ a.unlockState()
189+ return nil
190+ }
191+ before = parsed
192+ }
193+ if before.IsZero() {
194+ before = time.Now()
195+ }
196+ domain := a.historyDomainFilter
197+ history := a.deps.History
198+ dom := a.deps.DOM
199+ a.unlockState()
200+
201+ window, err := history.TimelineWindow(ctx, before, domain)
202+ if err != nil {
203+ return err
204+ }
205+ if window == nil {
206+ a.lockState()
207+ a.historyHasMore = false
208+ a.unlockState()
209+ return nil
210+ }
211+
212+ a.lockState()
213+ appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)
214+ a.historyEntries = append(a.historyEntries, window.Entries...)
215+ a.historyWindowBefore = window.Before
216+ a.historyWindowAfter = window.After
217+ a.historyHasMore = window.HasMore
218+ data := historyRenderData{
219+ Entries: window.Entries,
220+ Stats: a.historyStats,
221+ Domains: a.historyDomainStats,
222+ Query: a.historyQuery,
223+ DomainFilter: a.historyDomainFilter,
224+ Offset: a.historyOffset,
225+ Limit: historyTimelineLimit,
226+ WindowBefore: a.historyWindowBefore,
227+ WindowAfter: a.historyWindowAfter,
228+ HasMore: a.historyHasMore,
229+ AppendSkipFirstDate: appendSkipFirstDate,
230+ Notice: a.historyNotice,
231+ Error: a.historyError,
232+ }
233+ fragment := historyTimelineAppendHTML(data)
234+ var fallbackHTML string
235+ if _, ok := dom.(DOMHistoryTimelineAppender); !ok {
236+ fullData := data
237+ fullData.Entries = a.historyEntries
238+ title := historyDocumentTitle(fullData)
239+ a.renderedHTML = renderAppFrame(renderedPage{
240+ route: RouteHistory,
241+ title: title,
242+ subtitle: "History",
243+ subtitleDetail: historyTitleDetail(fullData),
244+ body: historyHTML(fullData),
245+ }, a.shellTheme)
246+ fallbackHTML = a.renderedHTML
247+ }
248+ a.unlockState()
249+
250+ if appender, ok := dom.(DOMHistoryTimelineAppender); ok {
251+ return appender.AppendHistoryTimeline(fragment)
252+ }
253+ return a.mountHTML(fallbackHTML)
254+}
255+
256+func (a *App) resetHistoryWindowState() {
257+ a.historyOffset = 0
258+ a.historyWindowBefore = time.Time{}
259+ a.historyWindowAfter = time.Time{}
260+ a.historyHasMore = false
261+}
262+
263+func (a *App) mountRenderedHTML() error {
264+ return a.mountHTML(a.renderedHTML)
265+}
266+
267+func (a *App) mountHTML(html string) error {
268+ if a == nil || a.deps.DOM == nil {
269+ return nil
270+ }
271+ return a.deps.DOM.Mount(html)
272+}
273+
274+func (a *App) mountHTMLIfCurrent(ctx context.Context, html string, generation uint64) error {
275+ if a == nil || a.deps.DOM == nil {
276+ return nil
277+ }
278+ if ctx != nil && ctx.Err() != nil {
279+ return nil
280+ }
281+ a.lockState()
282+ current := !a.closed && a.renderGeneration == generation
283+ a.unlockState()
284+ if !current {
285+ return nil
286+ }
287+ return a.deps.DOM.Mount(html)
288+}
289+
290+func isKnownHistoryCleanupRange(rangeID string) bool {
291+ for _, item := range historyCleanupItems() {
292+ if item.RangeID == rangeID {
293+ return true
294+ }
295+ }
296+ return false
297+}
298+
299+func historyRangeNotice(rangeID string) string {
300+ for _, item := range historyCleanupItems() {
301+ if item.RangeID == rangeID {
302+ return item.Notice
303+ }
304+ }
305+ return "Deleted history"
306+}
A internal/ui/systemviews/history_templ.go +978 -0 generated file: collapsed
A internal/ui/systemviews/history_view.go +372 -0
@@ -0,0 +1,372 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "net/url"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ browserurl "github.com/bnema/dumber/internal/domain/url"
11+)
12+
13+const historyURLDisplayMaxRunes = 180
14+
15+type historyRenderData struct {
16+ Entries []*entity.HistoryEntry
17+ Stats *entity.HistoryStats
18+ Analytics *entity.HistoryAnalytics
19+ Domains []*entity.DomainStat
20+ Query string
21+ DomainFilter string
22+ Offset int
23+ Limit int
24+ WindowBefore time.Time
25+ WindowAfter time.Time
26+ HasMore bool
27+ AppendSkipFirstDate string
28+ Notice string
29+ Error string
30+ Loading bool
31+}
32+
33+type historyTimelineGroup struct {
34+ Date string
35+ Label string
36+ Entries []*entity.HistoryEntry
37+}
38+
39+type historyCleanupItem struct {
40+ Label string
41+ RangeID string
42+ Confirm string
43+ Notice string
44+}
45+
46+func historyHTML(data historyRenderData) string {
47+ return mustRenderComponent(HistoryView(data))
48+}
49+
50+func historyTimelineAppendHTML(data historyRenderData) string {
51+ return mustRenderComponent(HistoryTimelineAppend(data))
52+}
53+
54+func historyDocumentTitle(data historyRenderData) string {
55+ return "History " + historyTitleDetail(data)
56+}
57+
58+func historyTitleDetail(data historyRenderData) string {
59+ if data.Loading {
60+ return "- Loading"
61+ }
62+ entries, visits, days := historySummaryValues(data)
63+ return fmt.Sprintf(
64+ "- %s, %s, %s",
65+ historyCountLabel(entries, "entry", "entries"),
66+ historyCountLabel(visits, "visit", "visits"),
67+ historyCountLabel(days, "day", "days"),
68+ )
69+}
70+
71+func historyCountLabel(count int64, singular, plural string) string {
72+ label := plural
73+ if count == 1 {
74+ label = singular
75+ }
76+ return fmt.Sprintf("%d %s", count, label)
77+}
78+
79+var historyCleanupRanges = []historyCleanupItem{
80+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"},
81+ {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"},
82+ {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"},
83+ {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"},
84+ {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
85+}
86+
87+func historyCleanupItems() []historyCleanupItem {
88+ return historyCleanupRanges
89+}
90+
91+func historyLimit(data historyRenderData) int {
92+ if data.Limit <= 0 {
93+ return 0
94+ }
95+ return data.Limit
96+}
97+
98+func historyIsPaginated(data historyRenderData) bool {
99+ return historyLimit(data) > 0
100+}
101+
102+func disableHistoryNext(data historyRenderData) bool {
103+ return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
104+}
105+
106+func showHistoryFilters(data historyRenderData) bool {
107+ return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
108+}
109+
110+func historyShowingLabel(data historyRenderData) string {
111+ if data.Loading {
112+ return "Loading history…"
113+ }
114+ count := countHistoryEntries(data.Entries)
115+ if strings.TrimSpace(data.Query) != "" {
116+ return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count))
117+ }
118+ if strings.TrimSpace(data.DomainFilter) != "" {
119+ return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count))
120+ }
121+ return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count))
122+}
123+
124+func pluralSuffix(count int) string {
125+ if count == 1 {
126+ return ""
127+ }
128+ return "s"
129+}
130+
131+func historyLoadMoreCursor(data historyRenderData) string {
132+ if data.WindowAfter.IsZero() {
133+ return ""
134+ }
135+ return data.WindowAfter.Format(time.RFC3339Nano)
136+}
137+
138+func showHistoryLoadMore(data historyRenderData) bool {
139+ return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore
140+}
141+
142+func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
143+ if data.Stats != nil {
144+ return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays
145+ }
146+ if data.Analytics != nil {
147+ return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays
148+ }
149+ entries = int64(countHistoryEntries(data.Entries))
150+ visits = sumHistoryVisits(data.Entries)
151+ uniqueDays = countUniqueHistoryDays(data.Entries)
152+
153+ return entries, visits, uniqueDays
154+}
155+
156+func historyTimelineEmptyMessage(data historyRenderData) string {
157+ if data.Loading {
158+ return "Loading history…"
159+ }
160+ if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
161+ return "No history entries match the current filters"
162+ }
163+ return "No history entries"
164+}
165+
166+func lastHistoryDateKey(entries []*entity.HistoryEntry) string {
167+ for i := len(entries) - 1; i >= 0; i-- {
168+ if entries[i] == nil {
169+ continue
170+ }
171+ dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local())
172+ return dateKey
173+ }
174+ return ""
175+}
176+
177+func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
178+ groups := make([]historyTimelineGroup, 0)
179+ indexByDate := map[string]int{}
180+ now := time.Now().Local()
181+ for _, entry := range entries {
182+ if entry == nil {
183+ continue
184+ }
185+ dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now)
186+ idx, ok := indexByDate[dateKey]
187+ if !ok {
188+ idx = len(groups)
189+ indexByDate[dateKey] = idx
190+ groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label})
191+ }
192+ groups[idx].Entries = append(groups[idx].Entries, entry)
193+ }
194+ return groups
195+}
196+
197+func historyDateKeyAndLabel(ts, now time.Time) (string, string) {
198+ if ts.IsZero() {
199+ return "unknown", "Unknown date"
200+ }
201+
202+ local := ts.Local()
203+ dateKey := local.Format("2006-01-02")
204+ today := now.Local()
205+ todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
206+ yesterdayStart := todayStart.AddDate(0, 0, -1)
207+ entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
208+
209+ switch {
210+ case entryStart.Equal(todayStart):
211+ return dateKey, "Today"
212+ case entryStart.Equal(yesterdayStart):
213+ return dateKey, "Yesterday"
214+ default:
215+ return dateKey, local.Format("Mon Jan 2")
216+ }
217+}
218+
219+func countHistoryEntries(entries []*entity.HistoryEntry) int {
220+ count := 0
221+ for _, entry := range entries {
222+ if entry != nil {
223+ count++
224+ }
225+ }
226+ return count
227+}
228+
229+func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
230+ var visits int64
231+ for _, entry := range entries {
232+ if entry == nil {
233+ continue
234+ }
235+ // Each HistoryEntry represents at least one visit, even when VisitCount
236+ // is unset or non-positive in older persisted rows.
237+ if entry.VisitCount <= 0 {
238+ visits++
239+ continue
240+ }
241+ visits += entry.VisitCount
242+ }
243+ return visits
244+}
245+
246+func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
247+ seen := map[string]struct{}{}
248+ for _, entry := range entries {
249+ if entry == nil || entry.LastVisited.IsZero() {
250+ continue
251+ }
252+ seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{}
253+ }
254+ return int64(len(seen))
255+}
256+
257+func displayHistoryDomain(raw string) string {
258+ return browserurl.DisplayDomain(raw)
259+}
260+
261+func historyDomainActionKey(domain *entity.DomainStat) string {
262+ if domain == nil {
263+ return ""
264+ }
265+ return browserurl.CanonicalDomain(domain.Domain)
266+}
267+
268+func historyDomainDisplayLabel(domain *entity.DomainStat) string {
269+ if domain == nil {
270+ return ""
271+ }
272+ return displayHistoryDomain(domain.Domain)
273+}
274+
275+func formatHistoryTime(ts time.Time) string {
276+ if ts.IsZero() {
277+ return ""
278+ }
279+ return ts.Local().Format("15:04")
280+}
281+
282+func historyItemLabel(entry *entity.HistoryEntry) string {
283+ if entry == nil {
284+ return ""
285+ }
286+ label := strings.TrimSpace(entry.Title)
287+ if label == "" {
288+ label = displayHistoryDomain(entry.URL)
289+ }
290+ if label == "" {
291+ label = entry.URL
292+ }
293+ return label
294+}
295+
296+func historyItemURL(entry *entity.HistoryEntry) string {
297+ if entry == nil {
298+ return ""
299+ }
300+ return truncateHistoryURL(entry.URL)
301+}
302+
303+func historyItemFaviconURL(entry *entity.HistoryEntry) string {
304+ if entry == nil {
305+ return ""
306+ }
307+ domain := browserurl.CanonicalDomain(entry.URL)
308+ if domain == "" {
309+ return ""
310+ }
311+ query := url.Values{}
312+ query.Set("domain", domain)
313+ query.Set("size", "32")
314+ return "/api/favicon?" + query.Encode()
315+}
316+
317+func historyFaviconClass(faviconURL string) string {
318+ if strings.TrimSpace(faviconURL) == "" {
319+ return "sv-history-favicon"
320+ }
321+ return "sv-history-favicon sv-history-favicon-has-image"
322+}
323+
324+func historyFaviconFallback(entry *entity.HistoryEntry) string {
325+ label := ""
326+ if entry != nil {
327+ label = displayHistoryDomain(entry.URL)
328+ }
329+ if label == "" {
330+ return "•"
331+ }
332+ for _, r := range label {
333+ return strings.ToUpper(string(r))
334+ }
335+ return "•"
336+}
337+
338+func truncateHistoryURL(raw string) string {
339+ raw = strings.TrimSpace(raw)
340+ if raw == "" {
341+ return ""
342+ }
343+ runes := []rune(raw)
344+ if len(runes) <= historyURLDisplayMaxRunes {
345+ return raw
346+ }
347+ return string(runes[:historyURLDisplayMaxRunes-1]) + "…"
348+}
349+
350+func historyItemMeta(entry *entity.HistoryEntry) string {
351+ if entry == nil {
352+ return ""
353+ }
354+ meta := []string{displayHistoryDomain(entry.URL)}
355+ if entry.VisitCount > 1 {
356+ meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
357+ }
358+ if !entry.LastVisited.IsZero() {
359+ meta = append(meta, formatHistoryTime(entry.LastVisited))
360+ }
361+ return strings.Join(nonEmptyStrings(meta), " · ")
362+}
363+
364+func nonEmptyStrings(values []string) []string {
365+ out := make([]string, 0, len(values))
366+ for _, value := range values {
367+ if strings.TrimSpace(value) != "" {
368+ out = append(out, value)
369+ }
370+ }
371+ return out
372+}
A internal/ui/systemviews/history_view_test.go +115 -0
@@ -0,0 +1,115 @@
1+package systemviews
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) {
13+ t.Parallel()
14+
15+ loc := time.FixedZone("test", 2*60*60)
16+ now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc)
17+
18+ todayKey, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)
19+ yesterdayKey, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)
20+ olderKey, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)
21+
22+ assert.Equal(t, "2026-04-24", todayKey)
23+ assert.Equal(t, "2026-04-23", yesterdayKey)
24+ assert.Equal(t, "2026-04-20", olderKey)
25+ assert.Equal(t, "Today", todayLabel)
26+ assert.Equal(t, "Yesterday", yesterdayLabel)
27+ assert.Equal(t, "Mon Apr 20", olderLabel)
28+}
29+
30+func TestHistorySummaryValuesUsesAllProvidedEntries(t *testing.T) {
31+ t.Parallel()
32+
33+ loc := time.FixedZone("test", 2*60*60)
34+ data := historyRenderData{Entries: []*entity.HistoryEntry{
35+ {VisitCount: 2, LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)},
36+ {VisitCount: 3, LastVisited: time.Date(2026, 4, 24, 8, 0, 0, 0, loc)},
37+ }}
38+
39+ entries, visits, days := historySummaryValues(data)
40+
41+ assert.Equal(t, int64(2), entries)
42+ assert.Equal(t, int64(5), visits)
43+ assert.Equal(t, int64(2), days)
44+}
45+
46+func TestHistoryShowingLabelDescribesUnpaginatedDefault(t *testing.T) {
47+ t.Parallel()
48+
49+ data := historyRenderData{Entries: []*entity.HistoryEntry{{}, {}}, Limit: 0}
50+
51+ assert.Equal(t, "Loaded 2 items", historyShowingLabel(data))
52+ assert.True(t, disableHistoryNext(data))
53+}
54+
55+func TestHistoryTimelineAppendMergesDuplicateFirstDayGroup(t *testing.T) {
56+ t.Parallel()
57+
58+ loc := time.FixedZone("test", 2*60*60)
59+ data := historyRenderData{
60+ Entries: []*entity.HistoryEntry{
61+ {URL: "https://same.example", Title: "Same day", LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)},
62+ {URL: "https://older.example", Title: "Older day", LastVisited: time.Date(2026, 4, 22, 8, 0, 0, 0, loc)},
63+ },
64+ AppendSkipFirstDate: "2026-04-23",
65+ }
66+
67+ html := historyTimelineAppendHTML(data)
68+
69+ assert.NotContains(t, html, ">Thu Apr 23</h3>")
70+ assert.NotContains(t, html, `<section class="sv-timeline-group"><ul class="sv-list">`)
71+ assert.Contains(t, html, `data-sv-history-merge-date="2026-04-23"`)
72+ assert.Contains(t, html, "Same day")
73+ assert.Contains(t, html, `data-sv-history-date="2026-04-22"`)
74+ assert.Contains(t, html, ">Wed Apr 22</h3>")
75+}
76+
77+func TestHistoryItemURLTruncatesLongURLs(t *testing.T) {
78+ t.Parallel()
79+
80+ longURL := "https://example.com/" + strings.Repeat("a", historyURLDisplayMaxRunes)
81+ entry := &entity.HistoryEntry{URL: longURL}
82+
83+ displayURL := historyItemURL(entry)
84+
85+ assert.LessOrEqual(t, len([]rune(displayURL)), historyURLDisplayMaxRunes)
86+ assert.Contains(t, displayURL, "…")
87+ assert.NotContains(t, displayURL, strings.Repeat("a", historyURLDisplayMaxRunes))
88+}
89+
90+func TestHistoryItemFaviconURLUsesLocalCachedFaviconAPI(t *testing.T) {
91+ t.Parallel()
92+
93+ entry := &entity.HistoryEntry{URL: "https://example.com/docs?token=secret#section"}
94+
95+ assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry))
96+}
97+
98+func TestHistoryItemFaviconURLKeepsPortsForCacheKeys(t *testing.T) {
99+ t.Parallel()
100+
101+ entry := &entity.HistoryEntry{URL: "http://localhost:1455/success?token=secret"}
102+
103+ assert.Equal(t, "/api/favicon?domain=localhost%3A1455&size=32", historyItemFaviconURL(entry))
104+}
105+
106+func TestHistoryItemFaviconURLIgnoresStoredRemoteFaviconURL(t *testing.T) {
107+ t.Parallel()
108+
109+ entry := &entity.HistoryEntry{
110+ URL: "https://example.com/docs",
111+ FaviconURL: "https://tracker.example/favicon.png",
112+ }
113+
114+ assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry))
115+}
A internal/ui/systemviews/href.go +44 -0
@@ -0,0 +1,44 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+const httpsScheme = "https"
9+
10+func sanitizeHref(raw string) string {
11+ trimmed := strings.TrimSpace(raw)
12+ if trimmed == "" || strings.HasPrefix(trimmed, "//") {
13+ return "#"
14+ }
15+
16+ parsed, err := url.Parse(trimmed)
17+ if err != nil {
18+ return "#"
19+ }
20+
21+ if parsed.Scheme == "" {
22+ if parsed.Host != "" {
23+ return "#"
24+ }
25+ return trimmed
26+ }
27+
28+ switch strings.ToLower(parsed.Scheme) {
29+ case httpScheme, httpsScheme:
30+ if parsed.Host == "" {
31+ return "#"
32+ }
33+ parsed.Scheme = strings.ToLower(parsed.Scheme)
34+ parsed.User = nil
35+ return parsed.String()
36+ case "dumb":
37+ if parsed.Host == "" && parsed.Opaque == "" {
38+ return "#"
39+ }
40+ return trimmed
41+ default:
42+ return "#"
43+ }
44+}
A internal/ui/systemviews/href_test.go +66 -0
@@ -0,0 +1,66 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
12+ t.Parallel()
13+
14+ html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{
15+ {URL: "javascript:alert(1)", Title: "Bad"},
16+ {URL: "https://example.com", Title: "Good"},
17+ }})
18+
19+ require.NotContains(t, html, `href="javascript:alert(1)"`)
20+ assert.Contains(t, html, `href="#"`)
21+ assert.Contains(t, html, `href="https://example.com"`)
22+}
23+
24+func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
25+ t.Parallel()
26+
27+ html := favoritesHTML(favoritesRenderData{Favorites: []*entity.Favorite{
28+ {URL: "javascript:alert(1)", Title: "Bad"},
29+ {URL: "https://example.com", Title: "Good"},
30+ }})
31+
32+ require.NotContains(t, html, `href="javascript:alert(1)"`)
33+ assert.Contains(t, html, `href="#"`)
34+ assert.Contains(t, html, `href="https://example.com"`)
35+}
36+
37+func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) {
38+ t.Parallel()
39+
40+ assert.Equal(t, "#", sanitizeHref("///evil.com"))
41+ assert.Equal(t, "#", sanitizeHref("//evil.com"))
42+ assert.Equal(t, "#", sanitizeHref("https:///evil.com"))
43+ assert.Equal(t, "#", sanitizeHref("http:evil.com"))
44+ assert.Equal(t, "https://example.com", sanitizeHref("https://example.com"))
45+ assert.Equal(t, "https://example.com/path", sanitizeHref("https://user:pass@example.com/path"))
46+}
47+
48+func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) {
49+ t.Parallel()
50+
51+ assert.Equal(t, "dumb://history", sanitizeHref("dumb://history"))
52+ assert.Equal(t, "dumb:history", sanitizeHref("dumb:history"))
53+ assert.Equal(t, "#", sanitizeHref("dumb:///history"))
54+}
55+
56+func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {
57+ t.Parallel()
58+
59+ html := favoritesHTML(favoritesRenderData{
60+ Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}},
61+ Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}},
62+ })
63+
64+ require.NotContains(t, html, `style="background:red`)
65+ assert.Contains(t, html, "background:#808080")
66+}
A internal/ui/systemviews/icons.go +26 -0
@@ -0,0 +1,26 @@
1+package systemviews
2+
3+type systemIcon string
4+
5+const (
6+ iconTrash systemIcon = "trash-2"
7+ iconX systemIcon = "x"
8+ iconSearch systemIcon = "search"
9+ iconSave systemIcon = "save"
10+ iconPlus systemIcon = "plus"
11+ iconExternal systemIcon = "external-link"
12+ iconRotateCCW systemIcon = "rotate-ccw"
13+ iconCheck systemIcon = "check"
14+ iconHistory systemIcon = "history"
15+ iconFolder systemIcon = "folder"
16+ iconTag systemIcon = "tag"
17+ iconSettings systemIcon = "settings"
18+ iconAlert systemIcon = "alert-triangle"
19+)
20+
21+func iconClass(name systemIcon) string {
22+ if name == "" {
23+ return "sv-icon"
24+ }
25+ return "sv-icon sv-icon-" + string(name)
26+}
A internal/ui/systemviews/icons.templ +65 -0
@@ -0,0 +1,65 @@
1+package systemviews
2+
3+templ Icon(name systemIcon) {
4+ <svg class={ iconClass(name) } xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
5+ if name == iconTrash {
6+ <path d="M10 11v6"></path>
7+ <path d="M14 11v6"></path>
8+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
9+ <path d="M3 6h18"></path>
10+ <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
11+ } else if name == iconX {
12+ <path d="M18 6 6 18"></path>
13+ <path d="m6 6 12 12"></path>
14+ } else if name == iconSearch {
15+ <path d="m21 21-4.34-4.34"></path>
16+ <circle cx="11" cy="11" r="8"></circle>
17+ } else if name == iconSave {
18+ <path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path>
19+ <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"></path>
20+ <path d="M7 3v4a1 1 0 0 0 1 1h7"></path>
21+ } else if name == iconPlus {
22+ <path d="M5 12h14"></path>
23+ <path d="M12 5v14"></path>
24+ } else if name == iconExternal {
25+ <path d="M15 3h6v6"></path>
26+ <path d="M10 14 21 3"></path>
27+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
28+ } else if name == iconRotateCCW {
29+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
30+ <path d="M3 3v5h5"></path>
31+ } else if name == iconCheck {
32+ <path d="M20 6 9 17l-5-5"></path>
33+ } else if name == iconHistory {
34+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
35+ <path d="M3 3v5h5"></path>
36+ <path d="M12 7v5l4 2"></path>
37+ } else if name == iconFolder {
38+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
39+ } else if name == iconTag {
40+ <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path>
41+ <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle>
42+ } else if name == iconSettings {
43+ <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"></path>
44+ <circle cx="12" cy="12" r="3"></circle>
45+ } else if name == iconAlert {
46+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
47+ <path d="M12 9v4"></path>
48+ <path d="M12 17h.01"></path>
49+ } else {
50+ <circle cx="12" cy="12" r="10"></circle>
51+ <path d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4"></path>
52+ <path d="M12 17h.01"></path>
53+ }
54+ </svg>
55+}
56+
57+templ ButtonIconLabel(name systemIcon, label string) {
58+ @Icon(name)
59+ <span>{ label }</span>
60+}
61+
62+templ IconOnly(name systemIcon, label string) {
63+ @Icon(name)
64+ <span class="sv-sr-only">{ label }</span>
65+}
A internal/ui/systemviews/icons_templ.go +224 -0 generated file: collapsed
A internal/ui/systemviews/prefers_dark_js.go +24 -0
@@ -0,0 +1,24 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import "syscall/js"
6+
7+var currentPrefersDarkImpl = func() bool {
8+ window := js.Global()
9+ if !window.Truthy() {
10+ return false
11+ }
12+
13+ matchMedia := window.Get("matchMedia")
14+ if !matchMedia.Truthy() {
15+ return false
16+ }
17+
18+ media := matchMedia.Invoke("(prefers-color-scheme: dark)")
19+ if !media.Truthy() {
20+ return false
21+ }
22+ matches := media.Get("matches")
23+ return matches.Truthy() && matches.Bool()
24+}
A internal/ui/systemviews/prefers_dark_stub.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package systemviews
4+
5+var currentPrefersDarkImpl = func() bool { return false }
A internal/ui/systemviews/render.go +29 -0
@@ -0,0 +1,29 @@
1+package systemviews
2+
3+import (
4+ "bytes"
5+ "context"
6+
7+ "github.com/a-h/templ"
8+)
9+
10+func renderComponent(ctx context.Context, component templ.Component) (string, error) {
11+ var buf bytes.Buffer
12+ if component == nil {
13+ return "", nil
14+ }
15+ if err := component.Render(ctx, &buf); err != nil {
16+ return "", err
17+ }
18+ return buf.String(), nil
19+}
20+
21+func mustRenderComponent(component templ.Component) string {
22+ // Render helpers are pure HTML formatting paths; route/action contexts are
23+ // handled before rendering, so there is no caller context to preserve here.
24+ html, err := renderComponent(context.Background(), component)
25+ if err != nil {
26+ return errorStateHTML(err.Error())
27+ }
28+ return html
29+}
A internal/ui/systemviews/router.go +60 -0
@@ -0,0 +1,60 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+type Route string
9+
10+const (
11+ RouteUnknown Route = "unknown"
12+ RouteHistory Route = "history"
13+ RouteFavorites Route = "favorites"
14+ RouteConfig Route = "config"
15+)
16+
17+func ParseRoute(uri string) Route {
18+ if uri == "" {
19+ return RouteUnknown
20+ }
21+
22+ u, err := url.Parse(uri)
23+ if err != nil {
24+ return RouteUnknown
25+ }
26+
27+ switch {
28+ case strings.EqualFold(u.Scheme, "dumb"):
29+ return routeFromPageHost(u.Host, u.Opaque)
30+ case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"):
31+ path := strings.Trim(u.Path, "/")
32+ if path == "" {
33+ return RouteUnknown
34+ }
35+ if head, _, ok := strings.Cut(path, "/"); ok {
36+ path = head
37+ }
38+ return routeFromPageHost(path, "")
39+ default:
40+ return RouteUnknown
41+ }
42+}
43+
44+func routeFromPageHost(host, opaque string) Route {
45+ page := strings.TrimSpace(host)
46+ if page == "" {
47+ page = strings.TrimSpace(opaque)
48+ }
49+
50+ switch strings.ToLower(page) {
51+ case string(RouteHistory):
52+ return RouteHistory
53+ case string(RouteFavorites):
54+ return RouteFavorites
55+ case string(RouteConfig):
56+ return RouteConfig
57+ default:
58+ return RouteUnknown
59+ }
60+}
A internal/ui/systemviews/shell.go +138 -0
@@ -0,0 +1,138 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/a-h/templ"
9+)
10+
11+type kvPair struct {
12+ Label string
13+ Value string
14+}
15+
16+// renderedPage holds the data needed by renderAppFrame to produce the shell
17+// fragment. Route views populate this; the app frame wraps it.
18+type renderedPage struct {
19+ route Route
20+ title string
21+ subtitle string
22+ subtitleDetail string
23+ body string
24+}
25+
26+// renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
27+// or <body>) that wraps route content in the shared shell. page.body is
28+// inserted with templ.Raw, so callers must only pass trusted pre-rendered HTML.
29+func renderAppFrame(page renderedPage, theme shellTheme) string {
30+ return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))
31+}
32+
33+func pageDocumentTitle(page renderedPage) string {
34+ if title := strings.TrimSpace(page.title); title != "" {
35+ return title
36+ }
37+ if subtitle := strings.TrimSpace(page.subtitle); subtitle != "" {
38+ return subtitle
39+ }
40+ return routeSubtitle(page.route)
41+}
42+
43+func appRootClass(theme shellTheme) string {
44+ themeClass := theme.RootClass
45+ if themeClass == "" {
46+ themeClass = "sv-dark"
47+ }
48+ return "sv-app " + themeClass
49+}
50+
51+func sectionClass(className string) string {
52+ classes := "sv-section"
53+ if strings.TrimSpace(className) != "" {
54+ classes += " " + strings.TrimSpace(className)
55+ }
56+ return classes
57+}
58+
59+func alertClass(kind string) string {
60+ classes := "sv-alert"
61+ switch kind {
62+ case "error":
63+ return classes + " sv-alert-error"
64+ case "success":
65+ return classes + " sv-alert-success"
66+ default:
67+ return classes
68+ }
69+}
70+
71+func alertRole(kind string) string {
72+ if kind == "error" {
73+ return "alert"
74+ }
75+ return "status"
76+}
77+
78+func buttonClass(extra string) string {
79+ extra = strings.TrimSpace(extra)
80+ if extra == "" {
81+ return "sv-button"
82+ }
83+ return "sv-button " + extra
84+}
85+
86+func listHTML(rows, emptyMessage string) string {
87+ if strings.TrimSpace(rows) == "" {
88+ return emptyStateHTML(emptyMessage)
89+ }
90+
91+ return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)
92+}
93+
94+// listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.
95+func linkHTML(href, label string) string {
96+ return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))
97+}
98+
99+func kvHTML(rows []kvPair) string {
100+ var items strings.Builder
101+ for _, row := range rows {
102+ if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" {
103+ continue
104+ }
105+ items.WriteString(kvRowHTML(row.Label, row.Value))
106+ }
107+ if items.Len() == 0 {
108+ return emptyStateHTML("No configuration details")
109+ }
110+
111+ return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String())
112+}
113+
114+func kvRowHTML(label, value string) string {
115+ return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))
116+}
117+
118+// sectionHTML wraps content in a styled section with a heading. inner is
119+// passed through as raw HTML, so callers must provide trusted markup.
120+func sectionHTML(class, title, inner string) string {
121+ return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,
122+ sectionClass(class),
123+ html.EscapeString(title),
124+ inner,
125+ )
126+}
127+
128+// emptyStateHTML returns a standard empty-state placeholder.
129+func emptyStateHTML(message string) string {
130+ return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))
131+}
132+
133+func errorStateHTML(message string) string {
134+ return fmt.Sprintf(
135+ `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`,
136+ html.EscapeString(message),
137+ )
138+}
A internal/ui/systemviews/shell_test.go +176 -0
@@ -0,0 +1,176 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRenderAppFrame_ReturnsFragment(t *testing.T) {
10+ t.Parallel()
11+
12+ page := renderedPage{
13+ route: RouteHistory,
14+ subtitle: "History",
15+ body: `<ul><li>hello</li></ul>`,
16+ }
17+ theme := shellTheme{RootClass: "sv-dark"}
18+
19+ out := renderAppFrame(page, theme)
20+
21+ // Must be a fragment — no full document tags.
22+ assert.NotContains(t, out, "<html")
23+ assert.NotContains(t, out, "<head")
24+ assert.NotContains(t, out, "<body")
25+
26+ // Root div carries theme class.
27+ assert.Contains(t, out, `class="sv-app sv-dark"`)
28+
29+ // Route and document title are on the root element.
30+ assert.Contains(t, out, `data-route="history"`)
31+ assert.Contains(t, out, `data-page-title="History"`)
32+
33+ // Shell wrapper present.
34+ assert.Contains(t, out, `class="sv-shell"`)
35+
36+ // Body content passed through.
37+ assert.Contains(t, out, `<ul><li>hello</li></ul>`)
38+
39+ // Subtitle rendered.
40+ assert.Contains(t, out, "History")
41+}
42+
43+func TestRenderAppFrame_DefaultThemeClass(t *testing.T) {
44+ t.Parallel()
45+
46+ page := renderedPage{
47+ route: RouteFavorites,
48+ subtitle: "Favorites",
49+ body: `<p>content</p>`,
50+ }
51+ // Empty theme — should still get sv-app with sv-dark default.
52+ out := renderAppFrame(page, shellTheme{})
53+
54+ assert.Contains(t, out, `class="sv-app sv-dark"`)
55+ assert.Contains(t, out, `data-route="favorites"`)
56+ assert.Contains(t, out, `data-page-title="Favorites"`)
57+}
58+
59+func TestRenderAppFrame_DocumentTitleUsesExplicitTitle(t *testing.T) {
60+ t.Parallel()
61+
62+ page := renderedPage{
63+ route: RouteHistory,
64+ title: "History — 12 entries",
65+ subtitle: "Recent visits",
66+ body: `<p>content</p>`,
67+ }
68+
69+ out := renderAppFrame(page, shellTheme{})
70+
71+ assert.Contains(t, out, `data-page-title="History — 12 entries"`)
72+ assert.Contains(t, out, "Recent visits")
73+}
74+
75+func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {
76+ t.Parallel()
77+
78+ page := renderedPage{
79+ route: RouteConfig,
80+ subtitle: "Config",
81+ body: `<p>settings</p>`,
82+ }
83+ theme := shellTheme{
84+ RootClass: "sv-light",
85+ InlineVars: "--sv-background: #fff;",
86+ }
87+
88+ out := renderAppFrame(page, theme)
89+
90+ assert.Contains(t, out, `style="--sv-background: #fff;"`)
91+ assert.Contains(t, out, `class="sv-app sv-light"`)
92+}
93+
94+func TestListHTMLWrapsRows(t *testing.T) {
95+ t.Parallel()
96+
97+ out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here")
98+
99+ assert.Contains(t, out, `class="sv-list"`)
100+ assert.Contains(t, out, `class="sv-list-row"`)
101+ assert.Contains(t, out, "entry")
102+}
103+
104+func TestListHTMLEmptyState(t *testing.T) {
105+ t.Parallel()
106+
107+ out := listHTML("", "Nothing here")
108+
109+ assert.Contains(t, out, `class="sv-empty"`)
110+ assert.Contains(t, out, "Nothing here")
111+}
112+
113+func TestLinkHTMLSanitizesHref(t *testing.T) {
114+ t.Parallel()
115+
116+ out := linkHTML("javascript:alert(1)", "Bad")
117+
118+ assert.Contains(t, out, `class="sv-link"`)
119+ assert.Contains(t, out, `href="#"`)
120+ assert.Contains(t, out, "Bad")
121+}
122+
123+func TestKVHTMLRendersPairs(t *testing.T) {
124+ t.Parallel()
125+
126+ out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}})
127+
128+ assert.Contains(t, out, `class="sv-kv"`)
129+ assert.Contains(t, out, "Engine")
130+ assert.Contains(t, out, "webkit")
131+}
132+
133+func TestKVHTMLEmptySlice(t *testing.T) {
134+ t.Parallel()
135+
136+ out := kvHTML(nil)
137+
138+ assert.Contains(t, out, `class="sv-empty"`)
139+ assert.Contains(t, out, "No configuration details")
140+}
141+
142+func TestKVHTMLAllEmptyRows(t *testing.T) {
143+ t.Parallel()
144+
145+ out := kvHTML([]kvPair{{}, {Label: " ", Value: ""}})
146+
147+ assert.Contains(t, out, `class="sv-empty"`)
148+ assert.Contains(t, out, "No configuration details")
149+}
150+
151+func TestErrorStateHTML(t *testing.T) {
152+ t.Parallel()
153+
154+ out := errorStateHTML("boom")
155+
156+ assert.Contains(t, out, `class="sv-error"`)
157+ assert.Contains(t, out, `role="alert"`)
158+ assert.Contains(t, out, "boom")
159+}
160+
161+func TestSectionHTML(t *testing.T) {
162+ t.Parallel()
163+
164+ out := sectionHTML("test-section", "Test Title", "<p>inner</p>")
165+ assert.Contains(t, out, `class="sv-section test-section"`)
166+ assert.Contains(t, out, "Test Title")
167+ assert.Contains(t, out, "<p>inner</p>")
168+}
169+
170+func TestEmptyStateHTML(t *testing.T) {
171+ t.Parallel()
172+
173+ out := emptyStateHTML("Nothing here")
174+ assert.Contains(t, out, `class="sv-empty"`)
175+ assert.Contains(t, out, "Nothing here")
176+}
A internal/ui/systemviews/theme.go +190 -0
@@ -0,0 +1,190 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "unicode"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+)
10+
11+type shellTheme struct {
12+ RootClass string
13+ InlineVars string
14+}
15+
16+const (
17+ shellDarkClass = "sv-dark"
18+ shellLightClass = "sv-light"
19+)
20+
21+var cssNamedColors = func() map[string]struct{} {
22+ //nolint:lll // CSS named color list is clearer as a single literal.
23+ 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`)
24+ colors := make(map[string]struct{}, len(names))
25+ for _, name := range names {
26+ colors[name] = struct{}{}
27+ }
28+ return colors
29+}()
30+
31+func currentPrefersDark() bool {
32+ return currentPrefersDarkImpl()
33+}
34+
35+func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme {
36+ if isZeroAppearance(appearance) {
37+ return shellTheme{}
38+ }
39+
40+ var palette dto.ColorPalette
41+ var rootClass string
42+
43+ switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
44+ case "prefer-dark":
45+ palette = appearance.DarkPalette
46+ rootClass = shellDarkClass
47+ case "prefer-light":
48+ palette = appearance.LightPalette
49+ rootClass = shellLightClass
50+ default:
51+ if currentPrefersDark() {
52+ palette = appearance.DarkPalette
53+ rootClass = shellDarkClass
54+ } else {
55+ palette = appearance.LightPalette
56+ rootClass = shellLightClass
57+ }
58+ }
59+
60+ return shellTheme{
61+ RootClass: rootClass,
62+ InlineVars: buildInlineVars(appearance, palette),
63+ }
64+}
65+
66+func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string {
67+ parts := make([]string, 0, 11)
68+ appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
69+ value, ok := sanitize(value)
70+ if !ok {
71+ return
72+ }
73+ parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
74+ }
75+
76+ appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor)
77+ appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor)
78+ appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor)
79+ appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor)
80+ appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor)
81+ appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor)
82+ appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor)
83+ appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily)
84+ appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily)
85+ appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily)
86+ if appearance.DefaultFontSize > 0 {
87+ parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
88+ }
89+
90+ return strings.Join(parts, " ")
91+}
92+
93+func sanitizeCSSColor(value string) (string, bool) {
94+ value = strings.TrimSpace(value)
95+ if value == "" || hasCSSBreakout(value) {
96+ return "", false
97+ }
98+ lower := strings.ToLower(value)
99+ if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) {
100+ return value, true
101+ }
102+ return "", false
103+}
104+
105+func sanitizeCSSFontFamily(value string) (string, bool) {
106+ value = strings.TrimSpace(value)
107+ if value == "" || hasCSSBreakout(value) {
108+ return "", false
109+ }
110+
111+ validated := value
112+ if quote := value[0]; quote == '\'' || quote == '"' {
113+ if len(value) < 2 || value[len(value)-1] != quote {
114+ return "", false
115+ }
116+ validated = value[1 : len(value)-1]
117+ if validated == "" || strings.ContainsAny(validated, "'\"") {
118+ return "", false
119+ }
120+ } else if strings.ContainsAny(value, "'\"") {
121+ return "", false
122+ }
123+
124+ for _, r := range validated {
125+ switch {
126+ case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r):
127+ case strings.ContainsRune(" ,._-", r):
128+ default:
129+ return "", false
130+ }
131+ }
132+ return value, true
133+}
134+
135+func hasCSSBreakout(value string) bool {
136+ return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/")
137+}
138+
139+func isHexColor(value string) bool {
140+ if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 {
141+ return false
142+ }
143+ if value[0] != '#' {
144+ return false
145+ }
146+ for _, r := range value[1:] {
147+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
148+ return false
149+ }
150+ }
151+ return true
152+}
153+
154+func isFunctionalColor(value string) bool {
155+ prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("}
156+ matched := false
157+ for _, prefix := range prefixes {
158+ if strings.HasPrefix(value, prefix) {
159+ matched = true
160+ break
161+ }
162+ }
163+ if !matched || !strings.HasSuffix(value, ")") {
164+ return false
165+ }
166+ for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
167+ if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) {
168+ return false
169+ }
170+ }
171+ return true
172+}
173+
174+func isNamedColor(value string) bool {
175+ if value == "transparent" || value == "currentcolor" {
176+ return true
177+ }
178+ _, ok := cssNamedColors[value]
179+ return ok
180+}
181+
182+func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool {
183+ return appearance.ColorScheme == "" &&
184+ appearance.SansFont == "" &&
185+ appearance.SerifFont == "" &&
186+ appearance.MonospaceFont == "" &&
187+ appearance.DefaultFontSize == 0 &&
188+ appearance.LightPalette == (dto.ColorPalette{}) &&
189+ appearance.DarkPalette == (dto.ColorPalette{})
190+}
A internal/ui/systemviews/theme_test.go +131 -0
@@ -0,0 +1,131 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) {
11+ t.Parallel()
12+
13+ vars := buildInlineVars(dto.WebUIAppearanceConfig{
14+ SansFont: "Inter; color:red",
15+ SerifFont: "Georgia",
16+ MonospaceFont: "JetBrains Mono",
17+ LightPalette: dto.ColorPalette{
18+ Background: "#ffffff; color:red",
19+ Surface: "rgb(1 2 3 / 50%)",
20+ Text: "transparent",
21+ Accent: "definitelynotacolor",
22+ Border: "#ABCDEF80",
23+ },
24+ }, dto.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+
70+func TestResolveShellTheme(t *testing.T) {
71+ orig := currentPrefersDarkImpl
72+ t.Cleanup(func() {
73+ currentPrefersDarkImpl = orig
74+ })
75+
76+ appearance := dto.WebUIAppearanceConfig{
77+ SansFont: "Inter",
78+ SerifFont: "Georgia",
79+ MonospaceFont: "JetBrains Mono",
80+ DefaultFontSize: 16,
81+ ColorScheme: "default",
82+ LightPalette: dto.ColorPalette{
83+ Background: "#ffffff",
84+ Surface: "#f8f8f8",
85+ SurfaceVariant: "#eeeeee",
86+ Text: "#111111",
87+ Muted: "#666666",
88+ Accent: "#0055ff",
89+ Border: "#dddddd",
90+ },
91+ DarkPalette: dto.ColorPalette{
92+ Background: "#111111",
93+ Surface: "#1a1a1a",
94+ SurfaceVariant: "#2a2a2a",
95+ Text: "#f5f5f5",
96+ Muted: "#a0a0a0",
97+ Accent: "#66aaff",
98+ Border: "#333333",
99+ },
100+ }
101+
102+ tests := []struct {
103+ name string
104+ colorScheme string
105+ prefersDark bool
106+ wantClass string
107+ wantBackground string
108+ wantSurfaceVariant string
109+ }{
110+ {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
111+ {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
112+ {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
113+ {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ currentPrefersDarkImpl = func() bool { return tt.prefersDark }
119+ ttAppearance := appearance
120+ ttAppearance.ColorScheme = tt.colorScheme
121+
122+ got := resolveShellTheme(ttAppearance)
123+
124+ assert.Equal(t, tt.wantClass, got.RootClass)
125+ assert.Contains(t, got.InlineVars, "--sv-background")
126+ assert.Contains(t, got.InlineVars, "--sv-font-sans")
127+ assert.Contains(t, got.InlineVars, tt.wantBackground)
128+ assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant)
129+ })
130+ }
131+}
D webui/components.json +0 -16
@@ -1,16 +0,0 @@
1-{
2- "$schema": "https://shadcn-svelte.com/schema.json",
3- "tailwind": {
4- "css": "src/styles/app.css",
5- "baseColor": "neutral"
6- },
7- "aliases": {
8- "components": "$lib/components",
9- "utils": "$lib/utils",
10- "ui": "$lib/components/ui",
11- "hooks": "$lib/hooks",
12- "lib": "$lib"
13- },
14- "typescript": true,
15- "registry": "https://shadcn-svelte.com/registry"
16-}
D webui/eslint.config.js +0 -59
@@ -1,59 +0,0 @@
1-import js from "@eslint/js";
2-import svelte from "eslint-plugin-svelte";
3-import globals from "globals";
4-import tseslint from "typescript-eslint";
5-import svelteConfig from "./svelte.config.js";
6-
7-export default tseslint.config(
8- js.configs.recommended,
9- ...tseslint.configs.recommended,
10- ...svelte.configs.recommended,
11- {
12- languageOptions: {
13- globals: {
14- ...globals.browser,
15- },
16- },
17- },
18- {
19- files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
20- languageOptions: {
21- parserOptions: {
22- extraFileExtensions: [".svelte"],
23- parser: tseslint.parser,
24- svelteConfig,
25- },
26- },
27- },
28- {
29- files: ["**/*.{js,mjs,cjs,ts}"],
30- languageOptions: {
31- ecmaVersion: 2024,
32- sourceType: "module",
33- },
34- rules: {
35- // Modern TypeScript best practices
36- "@typescript-eslint/no-unused-vars": [
37- "error",
38- {
39- argsIgnorePattern: "^_",
40- varsIgnorePattern: "^_",
41- },
42- ],
43- "@typescript-eslint/no-explicit-any": "warn",
44- "@typescript-eslint/no-empty-function": "off",
45- "@typescript-eslint/consistent-type-imports": "error",
46- "@typescript-eslint/no-import-type-side-effects": "error",
47-
48- // Modern JavaScript/TypeScript patterns
49- "prefer-const": "error",
50- "no-var": "error",
51- "object-shorthand": "error",
52- "prefer-template": "error",
53- },
54- },
55- {
56- files: ["**/*.js", "**/*.mjs"],
57- ...tseslint.configs.disableTypeChecked,
58- },
59-);
D webui/package-lock.json +0 -3903 generated file: collapsed
D webui/package.json +0 -52
@@ -1,52 +0,0 @@
1-{
2- "name": "@dumber/gui",
3- "version": "0.1.0",
4- "private": true,
5- "type": "module",
6- "scripts": {
7- "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build",
8- "build:homepage": "VITE_PAGE=homepage vite build",
9- "build:error": "VITE_PAGE=error vite build",
10- "build:config": "VITE_PAGE=config vite build",
11- "build:webrtc": "VITE_PAGE=webrtc vite build",
12- "dev": "VITE_PAGE=homepage vite build --watch",
13- "dev:homepage": "VITE_PAGE=homepage vite build --watch",
14- "dev:error": "VITE_PAGE=error vite build --watch",
15- "dev:config": "VITE_PAGE=config vite build --watch",
16- "dev:webrtc": "VITE_PAGE=webrtc vite build --watch",
17- "check": "svelte-check",
18- "lint": "eslint src --fix",
19- "fmt": "prettier --write ."
20- },
21- "devDependencies": {
22- "@internationalized/date": "^3.12.0",
23- "@lucide/svelte": "^0.577.0",
24- "@sveltejs/vite-plugin-svelte": "^7.0.0",
25- "@tailwindcss/cli": "^4.2.2",
26- "@tailwindcss/postcss": "^4.2.2",
27- "@types/node": "^25.5.0",
28- "@typescript-eslint/eslint-plugin": "^8.57.1",
29- "@typescript-eslint/parser": "^8.57.1",
30- "autoprefixer": "^10.4.27",
31- "eslint": "^10.0.3",
32- "eslint-plugin-svelte": "^3.15.2",
33- "globals": "^17.4.0",
34- "postcss": "^8.5.8",
35- "svelte": "^5.54.0",
36- "svelte-awesome-color-picker": "^4.1.0",
37- "svelte-check": "^4.4.5",
38- "tailwind-variants": "^3.2.2",
39- "tailwindcss": "^4.0.0",
40- "tw-animate-css": "^1.4.0",
41- "typescript": "^5.3.0",
42- "typescript-eslint": "^8.57.1",
43- "vite": "^8.0.0"
44- },
45- "dependencies": {
46- "bits-ui": "^2.16.3",
47- "clsx": "^2.1.1",
48- "frappe-charts": "^1.6.2",
49- "mode-watcher": "^1.1.0",
50- "tailwind-merge": "^3.5.0"
51- }
52-}
D webui/postcss.config.js +0 -6
@@ -1,6 +0,0 @@
1-export default {
2- plugins: {
3- "@tailwindcss/postcss": {},
4- autoprefixer: {},
5- },
6-};
D webui/public/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

D webui/src/lib/bridge.ts +0 -165
@@ -1,165 +0,0 @@
1-/**
2- * WebKit bridge utilities for communicating with the Go backend.
3- *
4- * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived).
5- * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it
6- * fails silently, dropping the message entirely. This module provides safe
7- * serialization that converts Proxies to plain objects before sending.
8- */
9-
10-export interface BridgeMessage {
11- type: string;
12- webviewId: number;
13- payload: Record<string, unknown>;
14-}
15-
16-interface WebKitBridge {
17- postMessage: (msg: unknown) => void;
18-}
19-
20-/**
21- * Get the native bridge, if available.
22- * WebKit exposes `window.webkit.messageHandlers.dumber` while CEF injects
23- * `window.dumber.postMessage`.
24- */
25-export function getWebKitBridge(): WebKitBridge | null {
26- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27- const bridge = (window as any).webkit?.messageHandlers?.dumber ?? (window as any).dumber;
28- return bridge && typeof bridge.postMessage === "function" ? bridge : null;
29-}
30-
31-/**
32- * Get the current webview ID assigned by the Go backend.
33- */
34-export function getWebViewId(): number {
35- // eslint-disable-next-line @typescript-eslint/no-explicit-any
36- return (window as any).__dumber_webview_id || 0;
37-}
38-
39-/**
40- * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects.
41- * Uses JSON serialization which strips Proxy wrappers.
42- *
43- * @throws Error if the object contains non-serializable values (functions, circular refs, etc.)
44- */
45-export function toPlainObject<T>(obj: T): T {
46- try {
47- return JSON.parse(JSON.stringify(obj));
48- } catch (err) {
49- const message = err instanceof Error ? err.message : String(err);
50- throw new Error(`Failed to serialize object for bridge: ${message}`);
51- }
52-}
53-
54-/**
55- * Post a message to the Go backend via the native bridge.
56- *
57- * Automatically:
58- * - Converts Svelte 5 Proxy objects to plain objects
59- * - Adds webviewId if not provided
60- * - Logs errors if serialization fails
61- *
62- * @throws Error if bridge is not available or serialization fails
63- */
64-export function postMessage(msg: BridgeMessage): void {
65- const bridge = getWebKitBridge();
66- if (!bridge) {
67- throw new Error("Dumber bridge not available");
68- }
69-
70- // Ensure webviewId is set
71- if (!msg.webviewId) {
72- msg.webviewId = getWebViewId();
73- }
74-
75- // Convert to plain object to strip Svelte 5 Proxies
76- // This is critical - WebKit silently drops messages with Proxy objects
77- const plainMsg = toPlainObject(msg);
78- bridge.postMessage(plainMsg);
79-}
80-
81-/**
82- * Internal callback registry using Map for tracking pending requests.
83- * Callbacks must also be exposed on window for Go backend to call,
84- * but we track them here for proper cleanup and to prevent leaks.
85- */
86-const pendingCallbacks = new Map<string, {
87- cleanup: () => void;
88- timeoutId: ReturnType<typeof setTimeout>;
89-}>();
90-
91-let callbackCounter = 0;
92-const CALLBACK_TIMEOUT_MS = 30000;
93-
94-/**
95- * Post a message and set up success/error callbacks with automatic cleanup.
96- * Uses unique IDs to prevent naming conflicts between concurrent requests.
97- *
98- * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
99- * @param payload - Message payload (will be serialized to strip Proxies)
100- * @param successCallback - Base name for success callback (will be made unique)
101- * @param errorCallback - Base name for error callback (will be made unique)
102- * @param onSuccess - Handler for success response
103- * @param onError - Handler for error response
104- */
105-export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
106- type: string,
107- payload: TPayload,
108- successCallback: string,
109- errorCallback: string,
110- onSuccess: (response: TResponse) => void,
111- onError: (error: string) => void,
112-): void {
113- const id = ++callbackCounter;
114- const successKey = `${successCallback}_${id}`;
115- const errorKey = `${errorCallback}_${id}`;
116-
117- // Cleanup removes from both window and our tracking Map
118- const cleanup = () => {
119- const entry = pendingCallbacks.get(successKey);
120- if (entry) {
121- clearTimeout(entry.timeoutId);
122- pendingCallbacks.delete(successKey);
123- }
124- // eslint-disable-next-line @typescript-eslint/no-explicit-any
125- delete (window as any)[successKey];
126- // eslint-disable-next-line @typescript-eslint/no-explicit-any
127- delete (window as any)[errorKey];
128- };
129-
130- // Timeout prevents memory leaks if backend never responds
131- const timeoutId = setTimeout(() => {
132- cleanup();
133- onError("Request timed out");
134- }, CALLBACK_TIMEOUT_MS);
135-
136- // Track in our Map for proper lifecycle management
137- pendingCallbacks.set(successKey, { cleanup, timeoutId });
138-
139- // Expose on window for Go backend (it calls window[callbackName](response))
140- // eslint-disable-next-line @typescript-eslint/no-explicit-any
141- (window as any)[successKey] = (response: TResponse) => {
142- cleanup();
143- onSuccess(response);
144- };
145- // eslint-disable-next-line @typescript-eslint/no-explicit-any
146- (window as any)[errorKey] = (msg: unknown) => {
147- cleanup();
148- onError(typeof msg === "string" ? msg : "Unknown error");
149- };
150-
151- try {
152- postMessage({
153- type,
154- webviewId: getWebViewId(),
155- payload: {
156- ...payload,
157- successCallback: successKey,
158- errorCallback: errorKey,
159- },
160- });
161- } catch (err) {
162- cleanup();
163- onError(err instanceof Error ? err.message : String(err));
164- }
165-}
D webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +0 -27
@@ -1,27 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { buttonVariants } from "$lib/components/ui/button/index.js";
4- import { cn } from "$lib/utils.js";
5- import type { Snippet } from "svelte";
6- import type { MouseEventHandler } from "svelte/elements";
7-
8- let {
9- ref = $bindable(null),
10- class: className,
11- children,
12- onclick,
13- ...restProps
14- }: AlertDialogPrimitive.ActionProps & {
15- children?: Snippet;
16- onclick?: MouseEventHandler<HTMLButtonElement>;
17- } = $props();
18-</script>
19-
20-<AlertDialogPrimitive.Action
21- bind:ref
22- class={cn(buttonVariants(), className)}
23- {onclick}
24- {...restProps}
25->
26- {@render children?.()}
27-</AlertDialogPrimitive.Action>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { buttonVariants } from "$lib/components/ui/button/index.js";
4- import { cn } from "$lib/utils.js";
5- import type { Snippet } from "svelte";
6-
7- let {
8- ref = $bindable(null),
9- class: className,
10- children,
11- ...restProps
12- }: AlertDialogPrimitive.CancelProps & {
13- children?: Snippet;
14- } = $props();
15-</script>
16-
17-<AlertDialogPrimitive.Cancel
18- bind:ref
19- class={cn(buttonVariants({ variant: "outline" }), className)}
20- {...restProps}
21->
22- {@render children?.()}
23-</AlertDialogPrimitive.Cancel>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +0 -24
@@ -1,24 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4- import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5- import AlertDialogPortal from "./alert-dialog-portal.svelte";
6-
7- let {
8- ref = $bindable(null),
9- class: className,
10- ...restProps
11- }: AlertDialogPrimitive.ContentProps = $props();
12-</script>
13-
14-<AlertDialogPortal>
15- <AlertDialogOverlay />
16- <AlertDialogPrimitive.Content
17- bind:ref
18- class={cn(
19- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20- className
21- )}
22- {...restProps}
23- />
24-</AlertDialogPortal>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.DescriptionProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Description
13- bind:ref
14- class={cn("text-sm text-muted-foreground", className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16- {...restProps}
17->
18- {@render children?.()}
19-</div>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16- {...restProps}
17->
18- {@render children?.()}
19-</div>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.OverlayProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Overlay
13- bind:ref
14- class={cn(
15- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16- className
17- )}
18- {...restProps}
19-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +0 -7
@@ -1,7 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3-
4- let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5-</script>
6-
7-<AlertDialogPrimitive.Portal {...restProps} />
D webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.TitleProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Title
13- bind:ref
14- class={cn("text-lg font-semibold", className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.TriggerProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Trigger
13- bind:ref
14- class={cn(className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +0 -10
@@ -1,10 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3-
4- let {
5- open = $bindable(false),
6- ...restProps
7- }: AlertDialogPrimitive.RootProps = $props();
8-</script>
9-
10-<AlertDialogPrimitive.Root bind:open {...restProps} />
D webui/src/lib/components/ui/alert-dialog/index.ts +0 -37
@@ -1,37 +0,0 @@
1-import Root from "./alert-dialog.svelte";
2-import Trigger from "./alert-dialog-trigger.svelte";
3-import Portal from "./alert-dialog-portal.svelte";
4-import Overlay from "./alert-dialog-overlay.svelte";
5-import Content from "./alert-dialog-content.svelte";
6-import Header from "./alert-dialog-header.svelte";
7-import Footer from "./alert-dialog-footer.svelte";
8-import Title from "./alert-dialog-title.svelte";
9-import Description from "./alert-dialog-description.svelte";
10-import Action from "./alert-dialog-action.svelte";
11-import Cancel from "./alert-dialog-cancel.svelte";
12-
13-export {
14- Root,
15- Trigger,
16- Portal,
17- Overlay,
18- Content,
19- Header,
20- Footer,
21- Title,
22- Description,
23- Action,
24- Cancel,
25- //
26- Root as AlertDialog,
27- Trigger as AlertDialogTrigger,
28- Portal as AlertDialogPortal,
29- Overlay as AlertDialogOverlay,
30- Content as AlertDialogContent,
31- Header as AlertDialogHeader,
32- Footer as AlertDialogFooter,
33- Title as AlertDialogTitle,
34- Description as AlertDialogDescription,
35- Action as AlertDialogAction,
36- Cancel as AlertDialogCancel,
37-};
D webui/src/lib/components/ui/button/button.svelte +0 -82
@@ -1,82 +0,0 @@
1-<script lang="ts" module>
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
4- import { type VariantProps, tv } from "tailwind-variants";
5-
6- export const buttonVariants = tv({
7- base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8- variants: {
9- variant: {
10- default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
11- destructive:
12- "bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
13- outline:
14- "bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border border-border shadow-xs",
15- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
16- ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
17- link: "text-primary underline-offset-4 hover:underline",
18- },
19- size: {
20- default: "h-9 px-4 py-2 has-[>svg]:px-3",
21- sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
22- lg: "h-10 px-6 has-[>svg]:px-4",
23- icon: "size-9",
24- "icon-sm": "size-8",
25- "icon-lg": "size-10",
26- },
27- },
28- defaultVariants: {
29- variant: "default",
30- size: "default",
31- },
32- });
33-
34- export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
35- export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
36-
37- export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
38- WithElementRef<HTMLAnchorAttributes> & {
39- variant?: ButtonVariant;
40- size?: ButtonSize;
41- };
42-</script>
43-
44-<script lang="ts">
45- let {
46- class: className,
47- variant = "default",
48- size = "default",
49- ref = $bindable(null),
50- href = undefined,
51- type = "button",
52- disabled,
53- children,
54- ...restProps
55- }: ButtonProps = $props();
56-</script>
57-
58-{#if href}
59- <a
60- bind:this={ref}
61- data-slot="button"
62- class={cn(buttonVariants({ variant, size }), className)}
63- href={disabled ? undefined : href}
64- aria-disabled={disabled}
65- role={disabled ? "link" : undefined}
66- tabindex={disabled ? -1 : undefined}
67- {...restProps}
68- >
69- {@render children?.()}
70- </a>
71-{:else}
72- <button
73- bind:this={ref}
74- data-slot="button"
75- class={cn(buttonVariants({ variant, size }), className)}
76- {type}
77- {disabled}
78- {...restProps}
79- >
80- {@render children?.()}
81- </button>
82-{/if}
D webui/src/lib/components/ui/button/index.ts +0 -17
@@ -1,17 +0,0 @@
1-import Root, {
2- type ButtonProps,
3- type ButtonSize,
4- type ButtonVariant,
5- buttonVariants,
6-} from "./button.svelte";
7-
8-export {
9- Root,
10- type ButtonProps as Props,
11- //
12- Root as Button,
13- buttonVariants,
14- type ButtonProps,
15- type ButtonSize,
16- type ButtonVariant,
17-};
D webui/src/lib/components/ui/card/card-action.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-action"
16- class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card-content.svelte +0 -15
@@ -1,15 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
14- {@render children?.()}
15-</div>
D webui/src/lib/components/ui/card/card-description.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
11-</script>
12-
13-<p
14- bind:this={ref}
15- data-slot="card-description"
16- class={cn("text-muted-foreground text-sm", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</p>
D webui/src/lib/components/ui/card/card-footer.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-footer"
16- class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card-header.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-header"
16- class={cn(
17- "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</div>
D webui/src/lib/components/ui/card/card-title.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-title"
16- class={cn("leading-none font-semibold", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card"
16- class={cn(
17- "bg-card text-card-foreground flex flex-col gap-6 border border-border py-6 shadow-sm",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</div>
D webui/src/lib/components/ui/card/index.ts +0 -25
@@ -1,25 +0,0 @@
1-import Root from "./card.svelte";
2-import Content from "./card-content.svelte";
3-import Description from "./card-description.svelte";
4-import Footer from "./card-footer.svelte";
5-import Header from "./card-header.svelte";
6-import Title from "./card-title.svelte";
7-import Action from "./card-action.svelte";
8-
9-export {
10- Root,
11- Content,
12- Description,
13- Footer,
14- Header,
15- Title,
16- Action,
17- //
18- Root as Card,
19- Content as CardContent,
20- Description as CardDescription,
21- Footer as CardFooter,
22- Header as CardHeader,
23- Title as CardTitle,
24- Action as CardAction,
25-};
D webui/src/lib/components/ui/color-picker/color-picker.svelte +0 -80
@@ -1,80 +0,0 @@
1-<script lang="ts" module>
2- export type ColorPickerProps = {
3- value: string;
4- onValueChange?: (value: string) => void;
5- id?: string;
6- class?: string;
7- };
8-</script>
9-
10-<script lang="ts">
11- import ColorPicker from "svelte-awesome-color-picker";
12- import { Popover } from "bits-ui";
13- import { cn } from "$lib/utils.js";
14-
15- let {
16- class: className,
17- value = $bindable("#000000"),
18- onValueChange,
19- id,
20- }: ColorPickerProps = $props();
21-
22- let open = $state(false);
23-
24- function handleInput(event: { hex: string | null }) {
25- if (event.hex) {
26- value = event.hex;
27- onValueChange?.(event.hex);
28- }
29- }
30-</script>
31-
32-<Popover.Root bind:open>
33- <Popover.Trigger
34- {id}
35- class={cn(
36- "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
37- className
38- )}
39- >
40- <span
41- class="block h-6 w-8 border border-border/50"
42- style="background-color: {value};"
43- aria-hidden="true"
44- ></span>
45- <span class="sr-only">Choose color: {value}</span>
46- </Popover.Trigger>
47-
48- <Popover.Portal>
49- <Popover.Content
50- class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
51- sideOffset={4}
52- >
53- <ColorPicker
54- bind:hex={value}
55- onInput={handleInput}
56- isDialog={false}
57- />
58- <!-- Hex value display -->
59- <div class="mt-2 flex items-center gap-2">
60- <span
61- class="block h-6 w-6 shrink-0 border border-border"
62- style="background-color: {value};"
63- aria-hidden="true"
64- ></span>
65- <span class="font-mono text-sm text-foreground">{value}</span>
66- </div>
67- </Popover.Content>
68- </Popover.Portal>
69-</Popover.Root>
70-
71-<style>
72- /* Override svelte-awesome-color-picker styles to match our theme */
73- :global(.color-picker-popover) {
74- --cp-bg-color: var(--background);
75- --cp-border-color: var(--border);
76- --cp-text-color: var(--foreground);
77- --cp-input-color: var(--background);
78- --cp-button-hover-color: var(--accent);
79- }
80-</style>
D webui/src/lib/components/ui/color-picker/index.ts +0 -8
@@ -1,8 +0,0 @@
1-import Root, { type ColorPickerProps } from "./color-picker.svelte";
2-
3-export {
4- Root,
5- Root as ColorPicker,
6- type ColorPickerProps,
7- type ColorPickerProps as Props,
8-};
D webui/src/lib/components/ui/input/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./input.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Input,
7-};
D webui/src/lib/components/ui/input/input.svelte +0 -52
@@ -1,52 +0,0 @@
1-<script lang="ts">
2- import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- type InputType = Exclude<HTMLInputTypeAttribute, "file">;
6-
7- type Props = WithElementRef<
8- Omit<HTMLInputAttributes, "type"> &
9- ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
10- >;
11-
12- let {
13- ref = $bindable(null),
14- value = $bindable(),
15- type,
16- files = $bindable(),
17- class: className,
18- "data-slot": dataSlot = "input",
19- ...restProps
20- }: Props = $props();
21-</script>
22-
23-{#if type === "file"}
24- <input
25- bind:this={ref}
26- data-slot={dataSlot}
27- class={cn(
28- "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-border ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
29- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
30- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
31- className
32- )}
33- type="file"
34- bind:files
35- bind:value
36- {...restProps}
37- />
38-{:else}
39- <input
40- bind:this={ref}
41- data-slot={dataSlot}
42- class={cn(
43- "border-border bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
44- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
45- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
46- className
47- )}
48- {type}
49- bind:value
50- {...restProps}
51- />
52-{/if}
D webui/src/lib/components/ui/label/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./label.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Label,
7-};
D webui/src/lib/components/ui/label/label.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Label as LabelPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: LabelPrimitive.RootProps = $props();
10-</script>
11-
12-<LabelPrimitive.Root
13- bind:ref
14- data-slot="label"
15- class={cn(
16- "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/skeleton/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./skeleton.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Skeleton,
7-};
D webui/src/lib/components/ui/skeleton/skeleton.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
10-</script>
11-
12-<div
13- bind:this={ref}
14- data-slot="skeleton"
15- class={cn("bg-accent animate-pulse", className)}
16- {...restProps}
17-></div>
D webui/src/lib/components/ui/spinner/index.ts +0 -1
@@ -1,1 +0,0 @@
1-export { default as Spinner } from "./spinner.svelte";
D webui/src/lib/components/ui/spinner/spinner.svelte +0 -14
@@ -1,14 +0,0 @@
1-<script lang="ts">
2- import { cn } from "$lib/utils.js";
3- import Loader2Icon from "@lucide/svelte/icons/loader-2";
4- import type { ComponentProps } from "svelte";
5-
6- let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
7-</script>
8-
9-<Loader2Icon
10- role="status"
11- aria-label="Loading"
12- class={cn("size-4 animate-spin", className)}
13- {...restProps}
14-/>
D webui/src/lib/components/ui/table/index.ts +0 -28
@@ -1,28 +0,0 @@
1-import Root from "./table.svelte";
2-import Body from "./table-body.svelte";
3-import Caption from "./table-caption.svelte";
4-import Cell from "./table-cell.svelte";
5-import Footer from "./table-footer.svelte";
6-import Head from "./table-head.svelte";
7-import Header from "./table-header.svelte";
8-import Row from "./table-row.svelte";
9-
10-export {
11- Root,
12- Body,
13- Caption,
14- Cell,
15- Footer,
16- Head,
17- Header,
18- Row,
19- //
20- Root as Table,
21- Body as TableBody,
22- Caption as TableCaption,
23- Cell as TableCell,
24- Footer as TableFooter,
25- Head as TableHead,
26- Header as TableHeader,
27- Row as TableRow,
28-};
D webui/src/lib/components/ui/table/table-body.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<tbody
14- bind:this={ref}
15- data-slot="table-body"
16- class={cn("[&_tr:last-child]:border-0", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</tbody>
D webui/src/lib/components/ui/table/table-caption.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11-</script>
12-
13-<caption
14- bind:this={ref}
15- data-slot="table-caption"
16- class={cn("text-muted-foreground mt-4 text-sm", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</caption>
D webui/src/lib/components/ui/table/table-cell.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLTdAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLTdAttributes> = $props();
11-</script>
12-
13-<td
14- bind:this={ref}
15- data-slot="table-cell"
16- class={cn(
17- "bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</td>
D webui/src/lib/components/ui/table/table-footer.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<tfoot
14- bind:this={ref}
15- data-slot="table-footer"
16- class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</tfoot>
D webui/src/lib/components/ui/table/table-head.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLThAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLThAttributes> = $props();
11-</script>
12-
13-<th
14- bind:this={ref}
15- data-slot="table-head"
16- class={cn(
17- "text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</th>
D webui/src/lib/components/ui/table/table-header.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<thead
14- bind:this={ref}
15- data-slot="table-header"
16- class={cn("[&_tr]:border-b", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</thead>
D webui/src/lib/components/ui/table/table-row.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
11-</script>
12-
13-<tr
14- bind:this={ref}
15- data-slot="table-row"
16- class={cn(
17- "hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</tr>
D webui/src/lib/components/ui/table/table.svelte +0 -22
@@ -1,22 +0,0 @@
1-<script lang="ts">
2- import type { HTMLTableAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLTableAttributes> = $props();
11-</script>
12-
13-<div data-slot="table-container" class="relative w-full overflow-x-auto">
14- <table
15- bind:this={ref}
16- data-slot="table"
17- class={cn("w-full caption-bottom text-sm", className)}
18- {...restProps}
19- >
20- {@render children?.()}
21- </table>
22-</div>
D webui/src/lib/components/ui/tabs/index.ts +0 -16
@@ -1,16 +0,0 @@
1-import Root from "./tabs.svelte";
2-import Content from "./tabs-content.svelte";
3-import List from "./tabs-list.svelte";
4-import Trigger from "./tabs-trigger.svelte";
5-
6-export {
7- Root,
8- Content,
9- List,
10- Trigger,
11- //
12- Root as Tabs,
13- Content as TabsContent,
14- List as TabsList,
15- Trigger as TabsTrigger,
16-};
D webui/src/lib/components/ui/tabs/tabs-content.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.ContentProps = $props();
10-</script>
11-
12-<TabsPrimitive.Content
13- bind:ref
14- data-slot="tabs-content"
15- class={cn("flex-1 outline-none", className)}
16- {...restProps}
17-/>
D webui/src/lib/components/ui/tabs/tabs-list.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.ListProps = $props();
10-</script>
11-
12-<TabsPrimitive.List
13- bind:ref
14- data-slot="tabs-list"
15- class={cn(
16- "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center border border-border p-[3px]",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/tabs/tabs-trigger.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.TriggerProps = $props();
10-</script>
11-
12-<TabsPrimitive.Trigger
13- bind:ref
14- data-slot="tabs-trigger"
15- class={cn(
16- "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/tabs/tabs.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- value = $bindable(""),
8- class: className,
9- ...restProps
10- }: TabsPrimitive.RootProps = $props();
11-</script>
12-
13-<TabsPrimitive.Root
14- bind:ref
15- bind:value
16- data-slot="tabs"
17- class={cn("flex flex-col gap-2", className)}
18- {...restProps}
19-/>
D webui/src/lib/types.ts +0 -68
@@ -1,68 +0,0 @@
1-import type { HistoryEntry, SearchShortcut } from "../types/generated.js";
2-
3-type ThemeManagerAPI = {
4- setUserPreference: (theme: "light" | "dark") => void;
5- getCurrentTheme: () => "light" | "dark";
6-};
7-
8-// Global window API types for browser integration
9-declare global {
10- interface Window {
11- // Toast system API
12- __dumber_toast_loaded?: boolean;
13- __dumber_showToast?: (
14- message: string,
15- duration?: number,
16- type?: "info" | "success" | "error",
17- ) => number | void;
18- __dumber_dismissToast?: (id: number) => void;
19- __dumber_clearToasts?: () => void;
20- __dumber_showZoomToast?: (zoomLevel: number) => void;
21-
22- // Theme integration
23- __dumber_initial_theme?: string;
24- __dumber_setTheme?: (theme: "light" | "dark") => void;
25- __dumber_color_scheme_manager?: ThemeManagerAPI;
26-
27- // History callbacks
28- __dumber_history_recent?: (data: HistoryEntry[]) => void;
29- __dumber_history_error?: (error: string) => void;
30-
31- // Shortcuts callbacks
32- __dumber_shortcuts_loaded?: (data: Record<string, SearchShortcut>) => void;
33- __dumber_shortcuts_error?: (error: string) => void;
34-
35- // WebKit message handler
36- webkit?: {
37- messageHandlers?: {
38- dumber?: {
39- postMessage: (message: unknown) => void;
40- };
41- };
42- };
43- }
44-}
45-
46-export interface ToastAPI {
47- show: (
48- message: string,
49- duration?: number,
50- type?: "info" | "success" | "error",
51- ) => number;
52- dismiss: (id: number) => void;
53- clear: () => void;
54- showZoom: (zoomLevel: number) => void;
55-}
56-
57-export interface ThemeAPI {
58- setTheme: (theme: "light" | "dark") => void;
59- getCurrentTheme: () => "light" | "dark";
60-}
61-
62-// Message types for WebKit communication
63-export interface BrowserMessage {
64- action: string;
65- data?: unknown;
66-}
67-
68-export {}; // Make this file a module
D webui/src/lib/utils.ts +0 -13
@@ -1,13 +0,0 @@
1-import { clsx, type ClassValue } from "clsx";
2-import { twMerge } from "tailwind-merge";
3-
4-export function cn(...inputs: ClassValue[]) {
5- return twMerge(clsx(inputs));
6-}
7-
8-// eslint-disable-next-line @typescript-eslint/no-explicit-any
9-export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
10-// eslint-disable-next-line @typescript-eslint/no-explicit-any
11-export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
12-export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
13-export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
D webui/src/lib/utils/keys.ts +0 -51
@@ -1,51 +0,0 @@
1-/**
2- * Format a keybinding string for display.
3- * Converts internal key names to user-friendly display format.
4- *
5- * Examples:
6- * "ctrl+shift+a" -> "Ctrl + Shift + A"
7- * "arrowleft" -> "Left"
8- * "alt+[" -> "Alt + ["
9- */
10-export function formatKey(key: string): string {
11- // Split by '+' separator first, then format each part
12- // This avoids replacing '+' characters that are actual key names
13- const parts = key.split("+");
14-
15- const formattedParts = parts.map((part) => {
16- const lower = part.toLowerCase();
17- switch (lower) {
18- case "arrowleft":
19- return "Left";
20- case "arrowright":
21- return "Right";
22- case "arrowup":
23- return "Up";
24- case "arrowdown":
25- return "Down";
26- case "ctrl":
27- return "Ctrl";
28- case "alt":
29- return "Alt";
30- case "shift":
31- return "Shift";
32- case "space":
33- return "Space";
34- case "enter":
35- return "Enter";
36- case "tab":
37- return "Tab";
38- case "backspace":
39- return "Backspace";
40- case "escape":
41- return "Esc";
42- default:
43- // Capitalize single letters, leave symbols as-is
44- return part.length === 1 && /[a-z]/i.test(part)
45- ? part.toUpperCase()
46- : part;
47- }
48- });
49-
50- return formattedParts.join(" + ");
51-}
D webui/src/pages/ConfigPage.svelte +0 -761
@@ -1,761 +0,0 @@
1-<script lang="ts">
2- import { onMount } from "svelte";
3- import { ModeWatcher, resetMode, setMode } from "mode-watcher";
4- import ConfigShell from "./config/ConfigShell.svelte";
5- import ShortcutsTable from "./config/ShortcutsTable.svelte";
6- import KeybindingsTab from "./config/KeybindingsTab.svelte";
7- import * as AlertDialog from "$lib/components/ui/alert-dialog";
8- import { Button } from "$lib/components/ui/button";
9- import { ColorPicker } from "$lib/components/ui/color-picker";
10- import { Input } from "$lib/components/ui/input";
11- import { Label } from "$lib/components/ui/label";
12- import { Spinner } from "$lib/components/ui/spinner";
13- import * as Card from "$lib/components/ui/card";
14- import * as Tabs from "$lib/components/ui/tabs";
15- import { FlaskConical, RefreshCw } from "@lucide/svelte";
16-
17- function getErrorMessage(e: unknown): string {
18- if (e instanceof Error) return e.message;
19- if (typeof e === "string") return e;
20- return "Unknown error";
21- }
22-
23- type ColorPalette = {
24- background: string;
25- surface: string;
26- surface_variant: string;
27- text: string;
28- muted: string;
29- accent: string;
30- border: string;
31- };
32-
33- type SearchShortcut = {
34- url: string;
35- description: string;
36- };
37-
38- type ResolvedPerformance = {
39- skia_cpu_threads: number;
40- skia_gpu_threads: number;
41- web_process_memory_mb: number;
42- network_process_memory_mb: number;
43- webview_pool_prewarm: number;
44- conservative_threshold: number;
45- strict_threshold: number;
46- };
47-
48- type HardwareInfo = {
49- cpu_cores: number;
50- cpu_threads: number;
51- total_ram_mb: number;
52- gpu_vendor: string;
53- gpu_name: string;
54- vram_mb: number;
55- };
56-
57- type CustomPerformance = {
58- skia_cpu_threads: number;
59- skia_gpu_threads: number;
60- web_process_memory_mb: number;
61- network_process_memory_mb: number;
62- webview_pool_prewarm: number;
63- };
64-
65- type PerformanceConfig = {
66- profile: string;
67- custom: CustomPerformance;
68- resolved: ResolvedPerformance;
69- hardware: HardwareInfo;
70- };
71-
72- type ConfigDTO = {
73- appearance: {
74- sans_font: string;
75- serif_font: string;
76- monospace_font: string;
77- default_font_size: number;
78- color_scheme: string;
79- light_palette: ColorPalette;
80- dark_palette: ColorPalette;
81- };
82- performance: PerformanceConfig;
83- default_ui_scale: number;
84- default_search_engine: string;
85- search_shortcuts: Record<string, SearchShortcut>;
86- engine_type: string;
87- };
88-
89- const PERFORMANCE_PROFILES = [
90- { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." },
91- { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." },
92- { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." },
93- { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." },
94- ];
95-
96- const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
97- { key: "background", label: "Background" },
98- { key: "surface", label: "Surface" },
99- { key: "surface_variant", label: "Surface Variant" },
100- { key: "text", label: "Text" },
101- { key: "muted", label: "Muted" },
102- { key: "accent", label: "Accent" },
103- { key: "border", label: "Border" },
104- ];
105-
106- function isHexColor(value: string): boolean {
107- return /^#[0-9a-fA-F]{6}$/.test(value);
108- }
109-
110- // UI state
111- let loading = $state(true);
112- let loadError = $state<string | null>(null);
113- let config = $state<ConfigDTO | null>(null);
114- let saving = $state(false);
115- let saveSuccess = $state(false);
116- let saveError = $state<string | null>(null);
117- let showRestartWarning = $state(false);
118- let themeEvents = $state(0);
119- let activeTab = $state("appearance");
120-
121- function getResolvedDarkPreference(fallback?: boolean): boolean {
122- if (typeof fallback === "boolean") {
123- return fallback;
124- }
125- if (typeof window.__dumber_cef_prefers_dark === "boolean") {
126- return window.__dumber_cef_prefers_dark;
127- }
128- if (typeof window.__dumber_gtk_prefers_dark === "boolean") {
129- return window.__dumber_gtk_prefers_dark;
130- }
131- return document.documentElement.classList.contains("dark");
132- }
133-
134- function applyResolvedTheme(prefersDark: boolean): void {
135- const root = document.documentElement;
136- root.classList.toggle("dark", prefersDark);
137- root.classList.toggle("light", !prefersDark);
138- root.style.colorScheme = prefersDark ? "dark" : "light";
139- window.__dumber_cef_prefers_dark = prefersDark;
140- window.__dumber_gtk_prefers_dark = prefersDark;
141- }
142-
143- function syncModeWatcherPreference(colorScheme: string | undefined, prefersDark?: boolean): void {
144- switch (colorScheme) {
145- case "prefer-dark":
146- setMode("dark");
147- applyResolvedTheme(true);
148- break;
149- case "prefer-light":
150- setMode("light");
151- applyResolvedTheme(false);
152- break;
153- default:
154- resetMode();
155- applyResolvedTheme(getResolvedDarkPreference(prefersDark));
156- break;
157- }
158- }
159-
160- function isPerformanceSupported(cfg: ConfigDTO | null): boolean {
161- return cfg != null && cfg.engine_type !== "cef";
162- }
163-
164- // Load config from API
165- async function loadConfig() {
166- loading = true;
167- loadError = null;
168- try {
169- const response = await fetch("/api/config");
170- if (!response.ok) throw new Error("Failed to fetch config");
171- config = (await response.json()) as ConfigDTO;
172- syncModeWatcherPreference(config.appearance.color_scheme);
173- if (!isPerformanceSupported(config) && activeTab === "performance") {
174- activeTab = "appearance";
175- }
176- } catch (e: unknown) {
177- loadError = getErrorMessage(e);
178- console.error("[config] load failed", e);
179- } finally {
180- loading = false;
181- }
182- }
183-
184- // Reload config (used after save to get normalized values)
185- function reloadConfig() {
186- loadConfig();
187- }
188-
189- let resetDialogOpen = $state(false);
190-
191- async function doResetToDefaults() {
192- saveSuccess = false;
193- try {
194- const response = await fetch("/api/config/default");
195- if (!response.ok) throw new Error("Failed to fetch default config");
196- config = (await response.json()) as ConfigDTO;
197- syncModeWatcherPreference(config.appearance.color_scheme);
198- if (!isPerformanceSupported(config) && activeTab === "performance") {
199- activeTab = "appearance";
200- }
201- } catch (e: unknown) {
202- console.error("[config] reset defaults failed", e);
203- }
204- resetDialogOpen = false;
205- }
206-
207- function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
208- const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber;
209- if (bridge && typeof bridge.postMessage === "function") {
210- return bridge;
211- }
212- return null;
213- }
214-
215- function getWebViewId(): number {
216- return window.__dumber_webview_id || 0;
217- }
218-
219- async function saveConfig() {
220- saving = true;
221- saveSuccess = false;
222- saveError = null;
223-
224- try {
225- const bridge = getWebKitBridge();
226- const webviewId = getWebViewId();
227-
228- console.debug("[config] save click", {
229- hasBridge: Boolean(bridge),
230- webviewId,
231- hasConfig: Boolean(config),
232- });
233-
234- if (!bridge) {
235- throw new Error("Dumber bridge not available (not running inside Dumber)");
236- }
237- if (!config) {
238- throw new Error("Config not loaded");
239- }
240-
241- let timeout: number | null = window.setTimeout(() => {
242- if (saving) {
243- console.warn("[config] save timed out (no callback)");
244- saveError = "Save timed out (no response from native handler)";
245- saving = false;
246- }
247- }, 8000);
248-
249- const clearSaveTimeout = () => {
250- if (timeout != null) {
251- window.clearTimeout(timeout);
252- timeout = null;
253- }
254- };
255-
256- window.__dumber_config_saved = (resp?: unknown) => {
257- clearSaveTimeout();
258- console.debug("[config] save success", resp);
259- saveSuccess = true;
260- saving = false;
261-
262- // Show restart warning if performance tab was active
263- if (activeTab === "performance" && isPerformanceSupported(config)) {
264- showRestartWarning = true;
265- }
266-
267- syncModeWatcherPreference(config?.appearance.color_scheme);
268-
269- // Refresh config from backend (in case watcher normalized values)
270- reloadConfig();
271-
272- setTimeout(() => {
273- saveSuccess = false;
274- }, 3000);
275- };
276- window.__dumber_config_error = (msg: unknown) => {
277- clearSaveTimeout();
278- console.error("[config] save error", msg);
279- saveError = typeof msg === "string" ? msg : "Failed to save config";
280- saving = false;
281- };
282-
283- const payload = $state.snapshot(config);
284-
285- try {
286- bridge.postMessage({
287- type: "save_config",
288- webview_id: webviewId,
289- payload,
290- });
291- } catch (postErr) {
292- console.error("[config] postMessage threw", postErr);
293- throw postErr;
294- }
295- } catch (e: unknown) {
296- console.error("[config] save exception", e);
297- saveError = getErrorMessage(e);
298- saving = false;
299- }
300- }
301-
302- function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
303- if (!config) return;
304- config.search_shortcuts = nextShortcuts;
305- }
306-
307- onMount(() => {
308- console.debug("[config] mount", {
309- webviewId: window.__dumber_webview_id,
310- hasBridge: Boolean(getWebKitBridge()),
311- });
312-
313- const onThemeChanged = (e: Event) => {
314- themeEvents += 1;
315- const detail = (e as CustomEvent<{ prefersDark?: boolean }>).detail;
316- syncModeWatcherPreference(config?.appearance.color_scheme, detail?.prefersDark);
317- console.debug("[config] theme changed", detail);
318- };
319- window.addEventListener("dumber:theme-changed", onThemeChanged);
320-
321- // Load config on mount
322- loadConfig();
323-
324- return () => {
325- window.removeEventListener("dumber:theme-changed", onThemeChanged);
326- };
327- });
328-</script>
329-
330-<ModeWatcher />
331-<ConfigShell>
332- <div class="flex w-full flex-col gap-6 px-6 py-8">
333- {#if loading}
334- <!-- Loading state with spinner -->
335- <div class="flex flex-col items-center justify-center gap-4 py-16">
336- <Spinner class="size-8" />
337- <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
338- Loading configuration…
339- </div>
340- </div>
341- {:else if loadError}
342- <!-- Error state -->
343- <div class="flex flex-col items-center justify-center gap-4 py-16">
344- <Card.Root class="max-w-md">
345- <Card.Header>
346- <Card.Title class="text-destructive">Failed to load config</Card.Title>
347- <Card.Description>{loadError}</Card.Description>
348- </Card.Header>
349- <Card.Content>
350- <Button variant="outline" onclick={reloadConfig}>Try again</Button>
351- </Card.Content>
352- </Card.Root>
353- </div>
354- {:else if config}
355- <Tabs.Root bind:value={activeTab} class="w-full">
356- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
357- <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
358- Config Workspace
359- </div>
360- <Tabs.List class="bg-muted/60">
361- <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
362- <Tabs.Trigger value="search">Search</Tabs.Trigger>
363- <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
364- <Tabs.Trigger
365- value="performance"
366- disabled={!isPerformanceSupported(config)}
367- title={!isPerformanceSupported(config) ? "Performance tuning is only available with the WebKit engine" : undefined}
368- >
369- Performance
370- </Tabs.Trigger>
371- </Tabs.List>
372- </div>
373-
374- {#if !isPerformanceSupported(config)}
375- <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
376- Performance settings are disabled while using the CEF engine because these options are specific to WebKitGTK.
377- </div>
378- {/if}
379-
380- <Tabs.Content value="appearance">
381- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
382- <Card.Header>
383- <Card.Title>Appearance</Card.Title>
384- <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
385- </Card.Header>
386- <Card.Content class="space-y-8">
387- <div class="grid gap-6 md:grid-cols-2">
388- <div class="space-y-2">
389- <Label for="sans_font">Sans-Serif Font</Label>
390- <Input id="sans_font" bind:value={config.appearance.sans_font} />
391- </div>
392- <div class="space-y-2">
393- <Label for="serif_font">Serif Font</Label>
394- <Input id="serif_font" bind:value={config.appearance.serif_font} />
395- </div>
396- <div class="space-y-2">
397- <Label for="monospace_font">Monospace Font</Label>
398- <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
399- </div>
400- <div class="space-y-2">
401- <Label for="font_size">Default Font Size</Label>
402- <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
403- </div>
404- <div class="space-y-2">
405- <Label for="color_scheme">Color Scheme</Label>
406- <select
407- id="color_scheme"
408- bind:value={config.appearance.color_scheme}
409- class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
410- >
411- <option value="default">Follow System</option>
412- <option value="prefer-dark">Always Dark</option>
413- <option value="prefer-light">Always Light</option>
414- </select>
415- </div>
416- <div class="space-y-2">
417- <Label for="ui_scale">UI Scale</Label>
418- <Input
419- id="ui_scale"
420- type="number"
421- step="0.1"
422- bind:value={config.default_ui_scale}
423- />
424- </div>
425- </div>
426-
427- <div class="space-y-6">
428- <div class="space-y-3">
429- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
430- Theme: Light
431- </div>
432- <div class="grid gap-4 md:grid-cols-2">
433- {#each PALETTE_FIELDS as field (field.key)}
434- <div class="space-y-2">
435- <Label for={`light_${field.key}`}>{field.label}</Label>
436- <div class="flex items-center gap-3">
437- <ColorPicker
438- id={`light_${field.key}`}
439- value={config.appearance.light_palette[field.key]}
440- onValueChange={(v) => {
441- config!.appearance.light_palette[field.key] = v;
442- }}
443- />
444- <Input bind:value={config.appearance.light_palette[field.key]} />
445- </div>
446- {#if !isHexColor(config.appearance.light_palette[field.key])}
447- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
448- {/if}
449- </div>
450- {/each}
451- </div>
452- </div>
453-
454- <div class="space-y-3">
455- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
456- Theme: Dark
457- </div>
458- <div class="grid gap-4 md:grid-cols-2">
459- {#each PALETTE_FIELDS as field (field.key)}
460- <div class="space-y-2">
461- <Label for={`dark_${field.key}`}>{field.label}</Label>
462- <div class="flex items-center gap-3">
463- <ColorPicker
464- id={`dark_${field.key}`}
465- value={config.appearance.dark_palette[field.key]}
466- onValueChange={(v) => {
467- config!.appearance.dark_palette[field.key] = v;
468- }}
469- />
470- <Input bind:value={config.appearance.dark_palette[field.key]} />
471- </div>
472- {#if !isHexColor(config.appearance.dark_palette[field.key])}
473- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
474- {/if}
475- </div>
476- {/each}
477- </div>
478- </div>
479- </div>
480- </Card.Content>
481- </Card.Root>
482- </Tabs.Content>
483-
484- <Tabs.Content value="search">
485- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
486- <Card.Header>
487- <Card.Title>Search</Card.Title>
488- <Card.Description>Default engine and shortcuts.</Card.Description>
489- </Card.Header>
490- <Card.Content class="space-y-8">
491- <div class="space-y-2">
492- <Label for="search_engine">Default Search Engine (use %s for query)</Label>
493- <Input id="search_engine" bind:value={config.default_search_engine} />
494- </div>
495-
496- <div class="space-y-3">
497- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
498- Search Shortcuts
499- </div>
500- <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
501- <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
502- </div>
503- </Card.Content>
504- </Card.Root>
505- </Tabs.Content>
506-
507- <Tabs.Content value="keybindings">
508- <KeybindingsTab />
509- </Tabs.Content>
510-
511- <Tabs.Content value="performance">
512- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
513- <Card.Header>
514- <Card.Title>Performance Profile</Card.Title>
515- <Card.Description>
516- WebKitGTK rendering and memory tuning presets.
517- </Card.Description>
518- </Card.Header>
519- <Card.Content class="space-y-6">
520- {#if !isPerformanceSupported(config)}
521- <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
522- This section is unavailable under the CEF engine.
523- </div>
524- {:else}
525- <!-- Detected Hardware Card -->
526- {#if config.performance?.hardware}
527- {@const hw = config.performance.hardware}
528- <div class="rounded-md border border-border bg-muted/30 px-4 py-3">
529- <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
530- Detected Hardware
531- </div>
532- <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
533- <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div>
534- <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div>
535- <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div>
536- <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div>
537- </div>
538- </div>
539- {/if}
540-
541- <!-- Experimental feature warning -->
542- <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3">
543- <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" />
544- <div class="space-y-1">
545- <p class="text-sm font-medium text-info">Experimental Feature</p>
546- <p class="text-xs text-muted-foreground">
547- Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version.
548- </p>
549- </div>
550- </div>
551-
552- <!-- Profile selector -->
553- {#if config.performance}
554- <div class="space-y-4">
555- {#each PERFORMANCE_PROFILES as profile (profile.value)}
556- <label
557- class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}"
558- >
559- <input
560- type="radio"
561- name="performance_profile"
562- value={profile.value}
563- checked={config.performance.profile === profile.value}
564- onchange={() => config!.performance.profile = profile.value}
565- class="mt-1 accent-primary"
566- />
567- <div class="space-y-1">
568- <div class="font-medium">{profile.label}</div>
569- <div class="text-sm text-muted-foreground">{profile.description}</div>
570- </div>
571- </label>
572- {/each}
573- </div>
574- {:else}
575- <div class="text-sm text-muted-foreground">
576- Performance settings not available. This may be due to a configuration error.
577- </div>
578- {/if}
579-
580- <!-- Custom profile fields -->
581- {#if config.performance?.profile === "custom"}
582- <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4">
583- <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
584- Custom Settings
585- </div>
586- <div class="grid gap-4 md:grid-cols-2">
587- <div class="space-y-2">
588- <Label for="skia_cpu_threads">Skia CPU Threads</Label>
589- <Input
590- id="skia_cpu_threads"
591- type="number"
592- min="0"
593- max="8"
594- bind:value={config.performance.custom.skia_cpu_threads}
595- />
596- <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p>
597- </div>
598- <div class="space-y-2">
599- <Label for="skia_gpu_threads">Skia GPU Threads</Label>
600- <Input
601- id="skia_gpu_threads"
602- type="number"
603- min="-1"
604- bind:value={config.performance.custom.skia_gpu_threads}
605- />
606- <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p>
607- </div>
608- <div class="space-y-2">
609- <Label for="web_process_memory">Web Process Memory (MB)</Label>
610- <Input
611- id="web_process_memory"
612- type="number"
613- min="0"
614- step="256"
615- bind:value={config.performance.custom.web_process_memory_mb}
616- />
617- <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
618- </div>
619- <div class="space-y-2">
620- <Label for="network_process_memory">Network Process Memory (MB)</Label>
621- <Input
622- id="network_process_memory"
623- type="number"
624- min="0"
625- step="128"
626- bind:value={config.performance.custom.network_process_memory_mb}
627- />
628- <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
629- </div>
630- <div class="space-y-2">
631- <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label>
632- <Input
633- id="webview_pool_prewarm"
634- type="number"
635- min="0"
636- max="20"
637- bind:value={config.performance.custom.webview_pool_prewarm}
638- />
639- <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p>
640- </div>
641- </div>
642- </div>
643- {/if}
644-
645- <!-- Resolved values display -->
646- {#if config.performance?.resolved}
647- <div class="space-y-3">
648- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
649- Resolved Settings (applied on restart)
650- </div>
651- <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm">
652- <div class="space-y-1">
653- <div class="flex justify-between">
654- <span class="font-medium">Skia CPU Threads</span>
655- <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span>
656- </div>
657- <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p>
658- </div>
659- <div class="space-y-1">
660- <div class="flex justify-between">
661- <span class="font-medium">Skia GPU Threads</span>
662- <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span>
663- </div>
664- <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p>
665- </div>
666- <div class="space-y-1">
667- <div class="flex justify-between">
668- <span class="font-medium">Web Process Memory</span>
669- <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span>
670- </div>
671- <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p>
672- </div>
673- <div class="space-y-1">
674- <div class="flex justify-between">
675- <span class="font-medium">Network Process Memory</span>
676- <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span>
677- </div>
678- <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p>
679- </div>
680- <div class="space-y-1">
681- <div class="flex justify-between">
682- <span class="font-medium">WebView Pool Prewarm</span>
683- <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span>
684- </div>
685- <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p>
686- </div>
687- </div>
688- </div>
689- {/if}
690- {/if}
691- </Card.Content>
692- </Card.Root>
693- </Tabs.Content>
694- </Tabs.Root>
695-
696- <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
697- <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
698- {#if saveSuccess}
699- <span class="text-primary">Saved</span>
700- {:else}
701- <span>Ready</span>
702- {/if}
703- {#if themeEvents > 0}
704- <span>Theme x{themeEvents}</span>
705- {/if}
706- </div>
707- {#if activeTab !== "keybindings"}
708- <div class="flex items-center gap-3">
709- <AlertDialog.Root bind:open={resetDialogOpen}>
710- <AlertDialog.Trigger disabled={saving}>
711- {#snippet child({ props })}
712- <Button variant="outline" {...props} type="button">
713- Reset Defaults
714- </Button>
715- {/snippet}
716- </AlertDialog.Trigger>
717- <AlertDialog.Content>
718- <AlertDialog.Header>
719- <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
720- <AlertDialog.Description>
721- This will reset all settings on this page to their default values.
722- You will still need to click Save to persist the changes.
723- </AlertDialog.Description>
724- </AlertDialog.Header>
725- <AlertDialog.Footer>
726- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
727- <AlertDialog.Action onclick={doResetToDefaults}>
728- Reset Defaults
729- </AlertDialog.Action>
730- </AlertDialog.Footer>
731- </AlertDialog.Content>
732- </AlertDialog.Root>
733- <Button onclick={saveConfig} disabled={saving} type="button">
734- {saving ? "Saving…" : "Save"}
735- </Button>
736- </div>
737- {/if}
738- </div>
739-
740- <!-- Save error display -->
741- {#if saveError}
742- <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
743- {saveError}
744- </div>
745- {/if}
746-
747- <!-- Restart warning after saving performance settings -->
748- {#if showRestartWarning}
749- <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3">
750- <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" />
751- <div class="space-y-1">
752- <p class="text-sm font-medium text-warning">Restart Required</p>
753- <p class="text-xs text-muted-foreground">
754- Performance settings are applied at browser startup. Restart Dumber for changes to take effect.
755- </p>
756- </div>
757- </div>
758- {/if}
759- {/if}
760- </div>
761-</ConfigShell>
D webui/src/pages/ErrorPage.svelte +0 -213
@@ -1,213 +0,0 @@
1-<script lang="ts">
2- import { onMount } from 'svelte';
3- import { ModeWatcher } from 'mode-watcher';
4- import { Button } from '$lib/components/ui/button';
5- import * as Card from '$lib/components/ui/card';
6-
7- type ErrorConfig = {
8- code: number;
9- title: string;
10- message: string;
11- icon: string;
12- actions: string[];
13- };
14-
15- const defaultConfig: ErrorConfig = {
16- code: 404,
17- title: 'Page Not Found',
18- message: 'The page you are looking for does not exist.',
19- icon: 'warning',
20- actions: ['back'],
21- };
22-
23- // Error configurations
24- const errorConfigs: Record<string, ErrorConfig> = {
25- blocked: { code: 0, title: 'Page Blocked', message: 'Content blocked by filter', icon: 'shield', actions: ['whitelist', 'bypass', 'back'] },
26- '404': defaultConfig,
27- '500': { code: 500, title: 'Server Error', message: 'Something went wrong on the server.', icon: 'error', actions: ['retry', 'back'] },
28- '502': { code: 502, title: 'Bad Gateway', message: 'The server received an invalid response.', icon: 'error', actions: ['retry', 'back'] },
29- '503': { code: 503, title: 'Service Unavailable', message: 'The server is temporarily unavailable.', icon: 'error', actions: ['retry', 'back'] },
30- offline: { code: 0, title: 'You Are Offline', message: 'Check your internet connection.', icon: 'offline', actions: ['retry', 'back'] },
31- ssl: { code: 0, title: 'Connection Not Secure', message: 'The security certificate is invalid.', icon: 'lock', actions: ['bypass', 'back'] },
32- timeout: { code: 0, title: 'Connection Timed Out', message: 'The server took too long to respond.', icon: 'warning', actions: ['retry', 'back'] },
33- };
34-
35- let config = $state<ErrorConfig>(defaultConfig);
36- let targetUrl = $state('');
37- let targetDomain = $state('');
38- let customMessage = $state('');
39- let isLoading = $state(false);
40- let actionError = $state('');
41-
42- onMount(() => {
43- const params = new URLSearchParams(window.location.search);
44- const typeParam = params.get('type') || params.get('code') || '404';
45-
46- const selectedConfig = errorConfigs[typeParam];
47- config = selectedConfig ?? {
48- code: parseInt(typeParam) || 0,
49- title: `Error ${typeParam}`,
50- message: 'An unexpected error occurred.',
51- icon: 'error',
52- actions: ['retry', 'back'],
53- };
54-
55- targetUrl = params.get('url') || '';
56- customMessage = params.get('reason') || params.get('message') || '';
57-
58- if (targetUrl) {
59- try {
60- targetDomain = new URL(targetUrl).hostname;
61- } catch {
62- targetDomain = targetUrl;
63- }
64- }
65- });
66-
67- function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
68- return new Promise((resolve, reject) => {
69- const requestId = `error_${Date.now()}_${Math.random().toString(36).slice(2)}`;
70- const handler = (event: MessageEvent) => {
71- if (event.data?.requestId === requestId) {
72- window.removeEventListener('message', handler);
73- if (event.data.success) {
74- resolve(event.data.data);
75- } else {
76- reject(new Error(event.data.error || 'Unknown error'));
77- }
78- }
79- };
80- window.addEventListener('message', handler);
81- if (window.webkit?.messageHandlers?.dumber) {
82- window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ type, requestId, payload }));
83- } else {
84- reject(new Error('WebKit message handler not available'));
85- }
86- setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Timeout')); }, 10000);
87- });
88- }
89-
90- async function addToWhitelist() {
91- if (!targetDomain) return;
92- isLoading = true;
93- actionError = '';
94- try {
95- await sendMessage('addToWhitelist', { domain: targetDomain });
96- window.location.href = targetUrl;
97- } catch (e) {
98- actionError = e instanceof Error ? e.message : 'Failed';
99- isLoading = false;
100- }
101- }
102-
103- async function bypassOnce() {
104- if (!targetUrl) return;
105- isLoading = true;
106- actionError = '';
107- try {
108- await sendMessage('bypassOnce', { url: targetUrl });
109- window.location.href = targetUrl;
110- } catch (e) {
111- actionError = e instanceof Error ? e.message : 'Failed';
112- isLoading = false;
113- }
114- }
115-
116- function retry() {
117- if (targetUrl) {
118- window.location.href = targetUrl;
119- } else {
120- window.location.reload();
121- }
122- }
123-
124- function goBack() {
125- window.history.back();
126- }
127-
128- const icons: Record<string, string> = {
129- shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
130- warning: '<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
131- error: '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>',
132- offline: '<line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>',
133- lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
134- };
135-
136- const iconColors: Record<string, string> = {
137- shield: 'text-primary',
138- warning: 'text-yellow-500',
139- error: 'text-destructive',
140- offline: 'text-muted-foreground',
141- lock: 'text-yellow-500',
142- };
143-</script>
144-
145-<svelte:head>
146- <title>{config.title}</title>
147-</svelte:head>
148-
149-<ModeWatcher />
150-<div class="min-h-screen flex items-center justify-center p-8 bg-background">
151- <Card.Root class="w-full max-w-md">
152- <Card.Header class="text-center">
153- <div class="flex justify-center mb-4">
154- <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
155- <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths -->
156- {@html icons[config.icon] || icons.error}
157- </svg>
158- </div>
159- {#if config.code > 0}
160- <div class="text-6xl font-bold text-muted-foreground/30 mb-2">{config.code}</div>
161- {/if}
162- <Card.Title class="text-xl">{config.title}</Card.Title>
163- <Card.Description>{customMessage || config.message}</Card.Description>
164- </Card.Header>
165-
166- <Card.Content class="space-y-4">
167- {#if targetUrl}
168- <div class="bg-muted p-3 rounded-md">
169- <p class="text-xs text-muted-foreground uppercase tracking-wide mb-1">URL</p>
170- <code class="text-sm break-all">{targetUrl}</code>
171- </div>
172- {/if}
173-
174- {#if actionError}
175- <div class="bg-destructive/10 border border-destructive/30 text-destructive p-3 rounded-md text-sm">
176- {actionError}
177- </div>
178- {/if}
179- </Card.Content>
180-
181- <Card.Footer class="flex flex-col gap-3">
182- {#if config.actions.includes('whitelist') && targetDomain}
183- <Button class="w-full" onclick={addToWhitelist} disabled={isLoading}>
184- {isLoading ? 'Adding...' : 'Add to Whitelist'}
185- </Button>
186- {/if}
187-
188- {#if config.actions.includes('bypass') && targetUrl}
189- <Button variant="outline" class="w-full" onclick={bypassOnce} disabled={isLoading}>
190- Proceed Anyway
191- </Button>
192- {/if}
193-
194- {#if config.actions.includes('retry')}
195- <Button class="w-full" onclick={retry} disabled={isLoading}>
196- Try Again
197- </Button>
198- {/if}
199-
200- {#if config.actions.includes('back')}
201- <Button variant="ghost" class="w-full" onclick={goBack} disabled={isLoading}>
202- Go Back
203- </Button>
204- {/if}
205-
206- {#if config.actions.includes('whitelist') && targetDomain}
207- <p class="text-xs text-muted-foreground text-center">
208- Adding <strong class="text-foreground">{targetDomain}</strong> to whitelist will disable content filtering.
209- </p>
210- {/if}
211- </Card.Footer>
212- </Card.Root>
213-</div>
D webui/src/pages/Homepage.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { ModeWatcher } from 'mode-watcher';
3- import { HomepageShell } from './homepage/shell';
4- import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
5- import { homepageState } from './homepage/state.svelte';
6-</script>
7-
8-<ModeWatcher />
9-<HomepageShell>
10- {#if homepageState.activePanel === 'history'}
11- <HistoryPanel />
12- {:else if homepageState.activePanel === 'favorites'}
13- <FavoritesPanel />
14- {:else if homepageState.activePanel === 'analytics'}
15- <AnalyticsPanel />
16- {/if}
17-</HomepageShell>
D webui/src/pages/config.ts +0 -10
@@ -1,10 +0,0 @@
1-import { mount } from "svelte";
2-import ConfigPage from "./ConfigPage.svelte";
3-import "../styles/app.css";
4-
5-// Mount the config page
6-const app = mount(ConfigPage, {
7- target: document.body,
8-});
9-
10-export default app;
D webui/src/pages/config/ConfigShell.svelte +0 -227 collapsed to keep the page light
D webui/src/pages/config/KeyCaptureModal.svelte +0 -184 collapsed to keep the page light
D webui/src/pages/config/KeybindingGroup.svelte +0 -95 collapsed to keep the page light
D webui/src/pages/config/KeybindingsTab.svelte +0 -340 collapsed to keep the page light
D webui/src/pages/config/ShortcutsTable.svelte +0 -170 collapsed to keep the page light
D webui/src/pages/error.ts +0 -11
@@ -1,11 +0,0 @@
1-import { mount } from "svelte";
2-import "../styles/app.css";
3-import ErrorPage from "./ErrorPage.svelte";
4-
5-console.log("[dumber] Mounting ErrorPage component");
6-try {
7- const app = mount(ErrorPage, { target: document.body });
8- console.log("[dumber] ErrorPage mounted successfully", app);
9-} catch (error) {
10- console.error("[dumber] Failed to mount ErrorPage:", error);
11-}
D webui/src/pages/homepage.ts +0 -37 collapsed to keep the page light
D webui/src/pages/homepage/analytics/DailyVisitsChart.svelte +0 -124 collapsed to keep the page light
D webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +0 -130 collapsed to keep the page light
D webui/src/pages/homepage/analytics/index.ts +0 -2
@@ -1,2 +0,0 @@
1-export { default as DailyVisitsChart } from './DailyVisitsChart.svelte';
2-export { default as HourlyActivityChart } from './HourlyActivityChart.svelte';
D webui/src/pages/homepage/favorites/FavoriteCard.svelte +0 -280 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FavoriteEditor.svelte +0 -495 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FavoriteGrid.svelte +0 -108 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FolderList.svelte +0 -312 collapsed to keep the page light
D webui/src/pages/homepage/favorites/TagCloud.svelte +0 -292 collapsed to keep the page light
D webui/src/pages/homepage/favorites/index.ts +0 -6
@@ -1,6 +0,0 @@
1-// Favorites Components - Folders, Tags, Grid, Cards, Editor
2-export { default as FavoriteCard } from './FavoriteCard.svelte';
3-export { default as FavoriteGrid } from './FavoriteGrid.svelte';
4-export { default as FolderList } from './FolderList.svelte';
5-export { default as TagCloud } from './TagCloud.svelte';
6-export { default as FavoriteEditor } from './FavoriteEditor.svelte';
D webui/src/pages/homepage/history/HistoryCleanup.svelte +0 -396 collapsed to keep the page light
D webui/src/pages/homepage/history/HistoryFilters.svelte +0 -155 collapsed to keep the page light
D webui/src/pages/homepage/history/HistoryItem.svelte +0 -280 collapsed to keep the page light
D webui/src/pages/homepage/history/HistorySearch.svelte +0 -179 collapsed to keep the page light
D webui/src/pages/homepage/history/HistoryTimeline.svelte +0 -223 collapsed to keep the page light
D webui/src/pages/homepage/history/index.ts +0 -6 collapsed to keep the page light
D webui/src/pages/homepage/keyboard.ts +0 -744 collapsed to keep the page light
D webui/src/pages/homepage/messaging.ts +0 -546 collapsed to keep the page light
D webui/src/pages/homepage/panels/AnalyticsPanel.svelte +0 -246 collapsed to keep the page light
D webui/src/pages/homepage/panels/FavoritesPanel.svelte +0 -287 collapsed to keep the page light
D webui/src/pages/homepage/panels/HistoryPanel.svelte +0 -210 collapsed to keep the page light
D webui/src/pages/homepage/panels/index.ts +0 -4 collapsed to keep the page light
D webui/src/pages/homepage/shell/CommandPalette.svelte +0 -613 collapsed to keep the page light
D webui/src/pages/homepage/shell/HomepageShell.svelte +0 -634 collapsed to keep the page light
D webui/src/pages/homepage/shell/KeyboardHints.svelte +0 -447 collapsed to keep the page light
D webui/src/pages/homepage/shell/StatusBar.svelte +0 -416 collapsed to keep the page light
D webui/src/pages/homepage/shell/index.ts +0 -5 collapsed to keep the page light
D webui/src/pages/homepage/state.svelte.ts +0 -504 collapsed to keep the page light
D webui/src/pages/homepage/types.ts +0 -232 collapsed to keep the page light
D webui/src/pages/webrtc.ts +0 -165 collapsed to keep the page light
D webui/src/styles/app.css +0 -332 collapsed to keep the page light
D webui/src/types/dumber.d.ts +0 -156 collapsed to keep the page light
D webui/src/types/frappe-charts.d.ts +0 -47 collapsed to keep the page light
D webui/src/types/generated.ts +0 -21 collapsed to keep the page light
D webui/src/utils/dom.ts +0 -203 collapsed to keep the page light
D webui/svelte.config.js +0 -10 collapsed to keep the page light
D webui/tailwind.config.js +0 -66 collapsed to keep the page light
D webui/tsconfig.json +0 -27 collapsed to keep the page light
D webui/vite-plugin-pages.ts +0 -153 collapsed to keep the page light
D webui/vite.config.ts +0 -123 collapsed to keep the page light

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