fix(systemviews): resolve follow-up review findings
- commit
- 1bac075c84c86dff017d315b0180ea7ef2e48571
- author
- brice · · 2026-04-24 09:54 UTC
- committer
- brice · · 2026-04-25 07:40 UTC
- parent
- e074a73
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
102 files · +4527 -1194
M .github/workflows/aur.yml +1 -1
| @@ -51,7 +51,7 @@ jobs: | |||
| 51 | 51 | "arch=('x86_64')" \ | |
| 52 | 52 | 'url="https://github.com/bnema/dumber"' \ | |
| 53 | 53 | "license=('MIT')" \ | |
| 54 | - | "makedepends=('go>=1.25' 'git')" \ | |
| 54 | + | "makedepends=('go>=1.26' 'git' 'brotli')" \ | |
| 55 | 55 | "depends=('gtk4' 'webkitgtk-6.0')" \ | |
| 56 | 56 | 'optdepends=(' \ | |
| 57 | 57 | " 'gst-plugins-base: Base media codecs'" \ | |
M .github/workflows/ci.yml +6 -6
| @@ -47,7 +47,7 @@ jobs: | |||
| 47 | 47 | - name: Install dependencies | |
| 48 | 48 | run: | | |
| 49 | 49 | pacman -Syu --noconfirm | |
| 50 | - | pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel | |
| 50 | + | pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli | |
| 51 | 51 | - uses: actions/checkout@v6 | |
| 52 | 52 | - name: Cache Go modules | |
| 53 | 53 | uses: actions/cache@v5 | |
| @@ -63,8 +63,8 @@ jobs: | |||
| 63 | 63 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | |
| 64 | 64 | restore-keys: | | |
| 65 | 65 | ${{ runner.os }}-go-build- | |
| 66 | - | - name: Build systemviews assets | |
| 67 | - | run: make build-systemviews | |
| 66 | + | - name: Build application and systemviews assets | |
| 67 | + | run: make build | |
| 68 | 68 | - name: Install golangci-lint | |
| 69 | 69 | run: | | |
| 70 | 70 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest | |
| @@ -83,7 +83,7 @@ jobs: | |||
| 83 | 83 | - name: Install dependencies | |
| 84 | 84 | run: | | |
| 85 | 85 | pacman -Syu --noconfirm | |
| 86 | - | pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel | |
| 86 | + | pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli | |
| 87 | 87 | - uses: actions/checkout@v6 | |
| 88 | 88 | - name: Cache Go modules | |
| 89 | 89 | uses: actions/cache@v5 | |
| @@ -99,8 +99,8 @@ jobs: | |||
| 99 | 99 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | |
| 100 | 100 | restore-keys: | | |
| 101 | 101 | ${{ runner.os }}-go-build- | |
| 102 | - | - name: Build systemviews assets | |
| 103 | - | run: make build-systemviews | |
| 102 | + | - name: Build application and systemviews assets | |
| 103 | + | run: make build | |
| 104 | 104 | - name: Run tests | |
| 105 | 105 | env: | |
| 106 | 106 | GDK_BACKEND: wayland | |
M .gitignore +1 -0
| @@ -65,6 +65,7 @@ tmp/go-mod/ | |||
| 65 | 65 | tmp/ | |
| 66 | 66 | assets/webui | |
| 67 | 67 | assets/systemviews/systemviews.wasm | |
| 68 | + | assets/systemviews/systemviews.wasm.br | |
| 68 | 69 | assets/systemviews/wasm_exec.js | |
| 69 | 70 | ||
| 70 | 71 | # Node/TypeScript related | |
M .mockery.yaml +1 -0
| @@ -88,6 +88,7 @@ packages: | |||
| 88 | 88 | UpdateChecker: {} | |
| 89 | 89 | UpdateDownloader: {} | |
| 90 | 90 | WebUIConfigSaver: {} | |
| 91 | + | SystemviewConfigReader: {} | |
| 91 | 92 | XDGPaths: {} | |
| 92 | 93 | github.com/bnema/dumber/internal/domain/repository: | |
| 93 | 94 | config: | |
M CONTRIBUTING.md +1 -1
| @@ -35,7 +35,7 @@ git push origin my-fix | |||
| 35 | 35 | 3. Run `go fmt` on any Go code changes | |
| 36 | 36 | ||
| 37 | 37 | **Systemviews assets:** | |
| 38 | - | 1. Run `make build-systemviews` to regenerate embedded WASM assets | |
| 38 | + | 1. If you modified systemviews files such as `cmd/systemviews/` or `.templ` components, run `make build-systemviews` to regenerate embedded WASM assets | |
| 39 | 39 | ||
| 40 | 40 | Keep commits focused and use single-line commit messages. | |
| 41 | 41 | ||
M Makefile +3 -2
| @@ -53,9 +53,10 @@ generate-systemviews: ## Generate Go code from systemviews templ components | |||
| 53 | 53 | ||
| 54 | 54 | build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |
| 55 | 55 | @echo "Building systemviews wasm assets..." | |
| 56 | + | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | |
| 56 | 57 | @mkdir -p assets/systemviews | |
| 57 | 58 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |
| 58 | - | GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 59 | + | GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 59 | 60 | brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br | |
| 60 | 61 | @echo "Systemviews build complete" | |
| 61 | 62 | ||
| @@ -145,7 +146,7 @@ clean: ## Clean build artifacts | |||
| 145 | 146 | @echo "Cleaning build artifacts..." | |
| 146 | 147 | rm -rf $(DIST_DIR) | |
| 147 | 148 | rm -f $(BINARY_NAME) | |
| 148 | - | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm | |
| 149 | + | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br | |
| 149 | 150 | rm -f coverage.out coverage.html | |
| 150 | 151 | go clean -cache | |
| 151 | 152 | go clean -testcache | |
M assets/systemviews/index.html +54 -17
| @@ -8,27 +8,64 @@ | |||
| 8 | 8 | <link rel="stylesheet" href="./systemviews.css"> | |
| 9 | 9 | <script src="./wasm_exec.js"></script> | |
| 10 | 10 | <script> | |
| 11 | - | const go = new Go(); | |
| 12 | - | WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject) | |
| 13 | - | .then((result) => go.run(result.instance)) | |
| 14 | - | .catch((err) => { | |
| 11 | + | function renderWasmError(prefixText, err) { | |
| 12 | + | const render = () => { | |
| 15 | 13 | const app = document.getElementById('app'); | |
| 16 | - | if (app) { | |
| 17 | - | app.innerHTML = ''; | |
| 18 | - | const message = document.createElement('div'); | |
| 19 | - | message.className = 'sv-error'; | |
| 20 | - | const prefix = document.createElement('span'); | |
| 21 | - | prefix.textContent = 'Failed to load systemviews WebAssembly: '; | |
| 22 | - | const details = document.createElement('span'); | |
| 23 | - | details.textContent = err && err.message ? err.message : String(err); | |
| 24 | - | message.append(prefix, details); | |
| 25 | - | app.append(message); | |
| 26 | - | } | |
| 27 | - | console.error('WASM load error:', err); | |
| 28 | - | }); | |
| 14 | + | if (!app) return; | |
| 15 | + | app.innerHTML = ''; | |
| 16 | + | const message = document.createElement('div'); | |
| 17 | + | message.className = 'sv-error'; | |
| 18 | + | const prefix = document.createElement('span'); | |
| 19 | + | prefix.textContent = prefixText; | |
| 20 | + | const details = document.createElement('span'); | |
| 21 | + | details.textContent = err && err.message ? err.message : String(err || 'unsupported browser'); | |
| 22 | + | message.append(prefix, details); | |
| 23 | + | app.append(message); | |
| 24 | + | }; | |
| 25 | + | if (document.readyState === 'loading' && !document.getElementById('app')) { | |
| 26 | + | document.addEventListener('DOMContentLoaded', render, { once: true }); | |
| 27 | + | } else { | |
| 28 | + | render(); | |
| 29 | + | } | |
| 30 | + | console.error(prefixText, err); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | function instantiateWasm(go, wasmURL) { | |
| 34 | + | const instantiateFromBytes = () => fetch(wasmURL) | |
| 35 | + | .then((response) => { | |
| 36 | + | if (!response.ok) { | |
| 37 | + | throw new Error(`HTTP ${response.status} loading ${wasmURL}`); | |
| 38 | + | } | |
| 39 | + | return response.arrayBuffer(); | |
| 40 | + | }) | |
| 41 | + | .then((bytes) => WebAssembly.instantiate(bytes, go.importObject)); | |
| 42 | + | ||
| 43 | + | if (!WebAssembly.instantiateStreaming) { | |
| 44 | + | return instantiateFromBytes(); | |
| 45 | + | } | |
| 46 | + | return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject) | |
| 47 | + | .catch((err) => { | |
| 48 | + | console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err); | |
| 49 | + | return instantiateFromBytes(); | |
| 50 | + | }); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if (typeof WebAssembly !== 'object') { | |
| 54 | + | renderWasmError('WebAssembly is not supported in this browser: '); | |
| 55 | + | } else if (typeof Go !== 'function') { | |
| 56 | + | renderWasmError('Go WebAssembly runtime failed to load: '); | |
| 57 | + | } else { | |
| 58 | + | const go = new Go(); | |
| 59 | + | instantiateWasm(go, './systemviews.wasm') | |
| 60 | + | .then((result) => go.run(result.instance)) | |
| 61 | + | .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err)); | |
| 62 | + | } | |
| 29 | 63 | </script> | |
| 30 | 64 | </head> | |
| 31 | 65 | <body> | |
| 32 | 66 | <main id="app">Loading systemviews…</main> | |
| 67 | + | <noscript> | |
| 68 | + | <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p> | |
| 69 | + | </noscript> | |
| 33 | 70 | </body> | |
| 34 | 71 | </html> | |
M assets/systemviews/systemviews.css +159 -31
| @@ -1,9 +1,17 @@ | |||
| 1 | 1 | :root { | |
| 2 | - | color-scheme: dark light; | |
| 3 | - | --sv-bg: #0f1115; | |
| 4 | - | --sv-fg: #e8ebf0; | |
| 2 | + | color-scheme: dark; | |
| 3 | + | --sv-background: #0f1115; | |
| 4 | + | --sv-surface: #171a21; | |
| 5 | + | --sv-surface-variant: #1d2129; | |
| 6 | + | --sv-text: #e8ebf0; | |
| 5 | 7 | --sv-muted: #9aa3b2; | |
| 6 | 8 | --sv-border: #2a313d; | |
| 9 | + | --sv-success: var(--sv-accent, #8ab4ff); | |
| 10 | + | --sv-danger: #ef4444; | |
| 11 | + | --sv-danger-text: #fecaca; | |
| 12 | + | --sv-radius-card: 0.5rem; | |
| 13 | + | --sv-radius-control: 0.35rem; | |
| 14 | + | --sv-radius-chip: 0.45rem; | |
| 7 | 15 | } | |
| 8 | 16 | ||
| 9 | 17 | body { | |
| @@ -16,8 +24,28 @@ body { | |||
| 16 | 24 | ||
| 17 | 25 | .sv-app { | |
| 18 | 26 | min-height: 100vh; | |
| 27 | + | background: var(--sv-background, #0f1115); | |
| 28 | + | color: var(--sv-text, #e8ebf0); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | .sv-app.sv-dark { | |
| 32 | + | color-scheme: dark; | |
| 19 | 33 | } | |
| 20 | 34 | ||
| 35 | + | .sv-app.sv-light { | |
| 36 | + | color-scheme: light; | |
| 37 | + | --sv-background: #f8fafc; | |
| 38 | + | --sv-surface: #ffffff; | |
| 39 | + | --sv-surface-variant: #eef2f7; | |
| 40 | + | --sv-text: #111827; | |
| 41 | + | --sv-muted: #64748b; | |
| 42 | + | --sv-border: #d7dee8; | |
| 43 | + | --sv-primary: #2563eb; | |
| 44 | + | --sv-danger: #dc2626; | |
| 45 | + | --sv-danger-text: #7f1d1d; | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | ||
| 21 | 49 | .sv-shell { | |
| 22 | 50 | display: grid; | |
| 23 | 51 | gap: 1rem; | |
| @@ -52,7 +80,7 @@ body { | |||
| 52 | 80 | .sv-list-row { | |
| 53 | 81 | padding: 0.75rem; | |
| 54 | 82 | border: 1px solid var(--sv-border, #2a313d); | |
| 55 | - | border-radius: 0.65rem; | |
| 83 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 56 | 84 | background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent); | |
| 57 | 85 | } | |
| 58 | 86 | ||
| @@ -81,14 +109,14 @@ body { | |||
| 81 | 109 | .sv-header { | |
| 82 | 110 | background: var(--sv-surface-variant, #1d2129); | |
| 83 | 111 | border: 1px solid var(--sv-border, #2a313d); | |
| 84 | - | border-radius: 0.75rem; | |
| 112 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 85 | 113 | padding: 1rem; | |
| 86 | 114 | } | |
| 87 | 115 | ||
| 88 | 116 | .sv-section { | |
| 89 | 117 | background: var(--sv-surface, #171a21); | |
| 90 | 118 | border: 1px solid var(--sv-border, #2a313d); | |
| 91 | - | border-radius: 0.75rem; | |
| 119 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 92 | 120 | padding: 1rem; | |
| 93 | 121 | } | |
| 94 | 122 | ||
| @@ -101,7 +129,7 @@ body { | |||
| 101 | 129 | margin-top: 1rem; | |
| 102 | 130 | padding: 1rem; | |
| 103 | 131 | border: 1px solid var(--sv-border, #2a313d); | |
| 104 | - | border-radius: 0.75rem; | |
| 132 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 105 | 133 | background: var(--sv-surface, #171a21); | |
| 106 | 134 | } | |
| 107 | 135 | ||
| @@ -156,7 +184,7 @@ body { | |||
| 156 | 184 | display: inline-flex; | |
| 157 | 185 | align-items: center; | |
| 158 | 186 | padding: 0.2rem 0.45rem; | |
| 159 | - | border-radius: 0.45rem; | |
| 187 | + | border-radius: var(--sv-radius-control, 0.35rem); | |
| 160 | 188 | border: 1px solid var(--sv-border, #2a313d); | |
| 161 | 189 | background: var(--sv-surface-variant, #1d2129); | |
| 162 | 190 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| @@ -187,7 +215,7 @@ body { | |||
| 187 | 215 | gap: 0.15rem; | |
| 188 | 216 | padding: 0.75rem; | |
| 189 | 217 | border: 1px solid var(--sv-border, #2a313d); | |
| 190 | - | border-radius: 0.65rem; | |
| 218 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 191 | 219 | background: var(--sv-surface-variant, #1d2129); | |
| 192 | 220 | } | |
| 193 | 221 | ||
| @@ -216,7 +244,7 @@ body { | |||
| 216 | 244 | gap: 0.15rem; | |
| 217 | 245 | padding: 0.45rem 0.65rem; | |
| 218 | 246 | border: 1px solid var(--sv-border, #2a313d); | |
| 219 | - | border-radius: 999px; | |
| 247 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |
| 220 | 248 | background: var(--sv-surface-variant, #1d2129); | |
| 221 | 249 | } | |
| 222 | 250 | ||
| @@ -260,9 +288,9 @@ body { | |||
| 260 | 288 | } | |
| 261 | 289 | ||
| 262 | 290 | .sv-error { | |
| 263 | - | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |
| 264 | - | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |
| 265 | - | border-radius: 0.75rem; | |
| 291 | + | border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d)); | |
| 292 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21)); | |
| 293 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 266 | 294 | color: var(--sv-text, #e8ebf0); | |
| 267 | 295 | padding: 1rem; | |
| 268 | 296 | } | |
| @@ -304,18 +332,26 @@ body { | |||
| 304 | 332 | font-weight: 600; | |
| 305 | 333 | } | |
| 306 | 334 | .sv-alert { | |
| 307 | - | border-radius: 0.75rem; | |
| 335 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 308 | 336 | padding: 0.75rem 1rem; | |
| 309 | 337 | border: 1px solid var(--sv-border, #2a313d); | |
| 338 | + | transition: opacity 180ms ease, transform 180ms ease; | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | .sv-alert-dismiss { | |
| 342 | + | opacity: 0; | |
| 343 | + | transform: translateY(-0.25rem); | |
| 310 | 344 | } | |
| 311 | 345 | ||
| 312 | 346 | .sv-alert-success { | |
| 313 | - | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21)); | |
| 347 | + | background: color-mix(in srgb, var(--sv-success, #8ab4ff) 14%, var(--sv-surface, #171a21)); | |
| 348 | + | border-color: color-mix(in srgb, var(--sv-success, #8ab4ff) 62%, var(--sv-border, #2a313d)); | |
| 314 | 349 | } | |
| 315 | 350 | ||
| 316 | 351 | .sv-alert-error { | |
| 317 | - | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | |
| 318 | - | border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | |
| 352 | + | color: var(--sv-danger-text, #fecaca); | |
| 353 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21)); | |
| 354 | + | border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 72%, var(--sv-border, #2a313d)); | |
| 319 | 355 | } | |
| 320 | 356 | ||
| 321 | 357 | .sv-toolbar, | |
| @@ -349,7 +385,7 @@ body { | |||
| 349 | 385 | ||
| 350 | 386 | .sv-search-label input { | |
| 351 | 387 | border: 1px solid var(--sv-border, #2a313d); | |
| 352 | - | border-radius: 0.55rem; | |
| 388 | + | border-radius: var(--sv-radius-control, 0.35rem); | |
| 353 | 389 | padding: 0.55rem 0.65rem; | |
| 354 | 390 | background: var(--sv-surface-variant, #1d2129); | |
| 355 | 391 | color: var(--sv-text, #e8ebf0); | |
| @@ -357,12 +393,13 @@ body { | |||
| 357 | 393 | } | |
| 358 | 394 | ||
| 359 | 395 | .sv-button { | |
| 396 | + | box-sizing: border-box; | |
| 360 | 397 | display: inline-flex; | |
| 361 | 398 | align-items: center; | |
| 362 | 399 | justify-content: center; | |
| 363 | 400 | min-height: 2rem; | |
| 364 | 401 | border: 1px solid var(--sv-accent, #8ab4ff); | |
| 365 | - | border-radius: 0.55rem; | |
| 402 | + | border-radius: var(--sv-radius-control, 0.35rem); | |
| 366 | 403 | padding: 0.35rem 0.65rem; | |
| 367 | 404 | background: var(--sv-accent, #8ab4ff); | |
| 368 | 405 | color: var(--sv-background, #0f1115); | |
| @@ -379,13 +416,18 @@ body { | |||
| 379 | 416 | } | |
| 380 | 417 | ||
| 381 | 418 | .sv-button-danger { | |
| 382 | - | border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d)); | |
| 383 | - | background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129)); | |
| 384 | - | color: #fecaca; | |
| 419 | + | border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d)); | |
| 420 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129)); | |
| 421 | + | color: var(--sv-danger-text, #fecaca); | |
| 385 | 422 | } | |
| 386 | 423 | ||
| 387 | 424 | .sv-button-danger:hover { | |
| 388 | - | background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129)); | |
| 425 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 24%, var(--sv-surface-variant, #1d2129)); | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | .sv-button-danger:disabled { | |
| 429 | + | color: var(--sv-danger-text, #fecaca); | |
| 430 | + | opacity: 0.7; | |
| 389 | 431 | } | |
| 390 | 432 | ||
| 391 | 433 | .sv-button:focus-visible, | |
| @@ -414,7 +456,7 @@ body { | |||
| 414 | 456 | gap: 0.35rem; | |
| 415 | 457 | align-items: center; | |
| 416 | 458 | border: 1px solid var(--sv-border, #2a313d); | |
| 417 | - | border-radius: 999px; | |
| 459 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |
| 418 | 460 | padding: 0.25rem 0.55rem; | |
| 419 | 461 | background: var(--sv-surface-variant, #1d2129); | |
| 420 | 462 | } | |
| @@ -446,7 +488,7 @@ body { | |||
| 446 | 488 | } | |
| 447 | 489 | ||
| 448 | 490 | .sv-danger { | |
| 449 | - | color: #fecaca; | |
| 491 | + | color: var(--sv-danger-text, #fecaca); | |
| 450 | 492 | } | |
| 451 | 493 | ||
| 452 | 494 | .sv-icon-button:hover, | |
| @@ -458,12 +500,12 @@ body { | |||
| 458 | 500 | .sv-history-item.sv-focused { | |
| 459 | 501 | outline: 2px solid var(--sv-accent, #8ab4ff); | |
| 460 | 502 | outline-offset: 0.35rem; | |
| 461 | - | border-radius: 0.5rem; | |
| 503 | + | border-radius: var(--sv-radius-control, 0.35rem); | |
| 462 | 504 | } | |
| 463 | 505 | ||
| 464 | 506 | .sv-key-hints kbd { | |
| 465 | 507 | border: 1px solid var(--sv-border, #2a313d); | |
| 466 | - | border-radius: 0.35rem; | |
| 508 | + | border-radius: 0.25rem; | |
| 467 | 509 | padding: 0.1rem 0.3rem; | |
| 468 | 510 | background: var(--sv-surface-variant, #1d2129); | |
| 469 | 511 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| @@ -481,7 +523,7 @@ body { | |||
| 481 | 523 | .sv-card-form { | |
| 482 | 524 | padding: 0.75rem; | |
| 483 | 525 | border: 1px solid var(--sv-border, #2a313d); | |
| 484 | - | border-radius: 0.75rem; | |
| 526 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 485 | 527 | background: var(--sv-surface-variant, #1d2129); | |
| 486 | 528 | } | |
| 487 | 529 | ||
| @@ -503,13 +545,37 @@ body { | |||
| 503 | 545 | .sv-fieldset input, | |
| 504 | 546 | .sv-fieldset select { | |
| 505 | 547 | border: 1px solid var(--sv-border, #2a313d); | |
| 506 | - | border-radius: 0.55rem; | |
| 548 | + | border-radius: var(--sv-radius-control, 0.35rem); | |
| 507 | 549 | padding: 0.5rem 0.6rem; | |
| 508 | 550 | background: var(--sv-surface-variant, #1d2129); | |
| 509 | 551 | color: var(--sv-text, #e8ebf0); | |
| 510 | 552 | font: inherit; | |
| 511 | 553 | } | |
| 512 | 554 | ||
| 555 | + | .sv-card-form input[type="color"], | |
| 556 | + | .sv-inline-form input[type="color"], | |
| 557 | + | .sv-fieldset input[type="color"] { | |
| 558 | + | width: 4rem; | |
| 559 | + | min-width: 4rem; | |
| 560 | + | height: 2.1rem; | |
| 561 | + | padding: 0.15rem; | |
| 562 | + | background: transparent; | |
| 563 | + | cursor: pointer; | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | .sv-card-form input[type="color"]::-webkit-color-swatch-wrapper, | |
| 567 | + | .sv-inline-form input[type="color"]::-webkit-color-swatch-wrapper, | |
| 568 | + | .sv-fieldset input[type="color"]::-webkit-color-swatch-wrapper { | |
| 569 | + | padding: 0; | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | .sv-card-form input[type="color"]::-webkit-color-swatch, | |
| 573 | + | .sv-inline-form input[type="color"]::-webkit-color-swatch, | |
| 574 | + | .sv-fieldset input[type="color"]::-webkit-color-swatch { | |
| 575 | + | border: 0; | |
| 576 | + | border-radius: calc(var(--sv-radius-control, 0.35rem) - 0.12rem); | |
| 577 | + | } | |
| 578 | + | ||
| 513 | 579 | .sv-config-form, | |
| 514 | 580 | .sv-fieldset { | |
| 515 | 581 | display: flex; | |
| @@ -523,7 +589,7 @@ body { | |||
| 523 | 589 | margin: 0; | |
| 524 | 590 | padding: 0.75rem; | |
| 525 | 591 | border: 1px solid var(--sv-border, #2a313d); | |
| 526 | - | border-radius: 0.65rem; | |
| 592 | + | border-radius: var(--sv-radius-card, 0.5rem); | |
| 527 | 593 | } | |
| 528 | 594 | ||
| 529 | 595 | .sv-fieldset legend { | |
| @@ -545,11 +611,73 @@ body { | |||
| 545 | 611 | margin-top: 0.75rem; | |
| 546 | 612 | } | |
| 547 | 613 | ||
| 614 | + | .sv-history-row-actions .sv-button { | |
| 615 | + | min-width: 4.75rem; | |
| 616 | + | } | |
| 617 | + | ||
| 548 | 618 | .sv-button-active { | |
| 549 | 619 | border-color: var(--sv-accent, #8ab4ff); | |
| 550 | 620 | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | |
| 551 | 621 | } | |
| 552 | 622 | ||
| 623 | + | .sv-tag-picker { | |
| 624 | + | display: grid; | |
| 625 | + | gap: 0.45rem; | |
| 626 | + | min-width: min(22rem, 100%); | |
| 627 | + | margin: 0; | |
| 628 | + | padding: 0; | |
| 629 | + | border: 0; | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | .sv-tag-picker legend { | |
| 633 | + | color: var(--sv-muted, #9aa3b2); | |
| 634 | + | font-size: 12px; | |
| 635 | + | font-weight: 600; | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | .sv-card-form .sv-tag-toggle, | |
| 639 | + | .sv-tag-toggle { | |
| 640 | + | display: inline-flex; | |
| 641 | + | min-width: 0; | |
| 642 | + | color: inherit; | |
| 643 | + | font-size: inherit; | |
| 644 | + | font-weight: inherit; | |
| 645 | + | cursor: pointer; | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | .sv-tag-toggle input { | |
| 649 | + | position: absolute; | |
| 650 | + | inline-size: 1px; | |
| 651 | + | block-size: 1px; | |
| 652 | + | opacity: 0; | |
| 653 | + | pointer-events: none; | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | .sv-tag-toggle span, | |
| 657 | + | .sv-tag-button { | |
| 658 | + | display: inline-flex; | |
| 659 | + | align-items: center; | |
| 660 | + | gap: 0.35rem; | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | .sv-tag-toggle > span { | |
| 664 | + | min-height: 1.45rem; | |
| 665 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 666 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |
| 667 | + | padding: 0.16rem 0.45rem; | |
| 668 | + | background: var(--sv-surface-variant, #1d2129); | |
| 669 | + | color: var(--sv-text, #e8ebf0); | |
| 670 | + | font-size: 12px; | |
| 671 | + | font-weight: 700; | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | .sv-tag-toggle input:checked + span, | |
| 675 | + | .sv-tag-button-active { | |
| 676 | + | border-color: var(--sv-accent, #8ab4ff); | |
| 677 | + | background: var(--sv-accent, #8ab4ff); | |
| 678 | + | color: var(--sv-background, #0f1115); | |
| 679 | + | } | |
| 680 | + | ||
| 553 | 681 | .sv-favorite-item { | |
| 554 | 682 | display: grid; | |
| 555 | 683 | gap: 0.75rem; | |
| @@ -581,7 +709,7 @@ body { | |||
| 581 | 709 | .sv-favorite-item.sv-focused { | |
| 582 | 710 | outline: 2px solid var(--sv-accent, #8ab4ff); | |
| 583 | 711 | outline-offset: 0.35rem; | |
| 584 | - | border-radius: 0.5rem; | |
| 712 | + | border-radius: var(--sv-radius-control, 0.35rem); | |
| 585 | 713 | } | |
| 586 | 714 | ||
| 587 | 715 | @media (max-width: 720px) { | |
D assets/systemviews/systemviews.wasm.br +0 -0 binary file: no text diff
binary file: no text diff
M aur/dumber-browser-git/PKGBUILD +1 -1
| @@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)" | |||
| 6 | 6 | arch=('x86_64') | |
| 7 | 7 | url="https://github.com/bnema/dumber" | |
| 8 | 8 | license=('MIT') | |
| 9 | - | makedepends=('go>=1.26' 'git') | |
| 9 | + | makedepends=('go>=1.26' 'git' 'brotli') | |
| 10 | 10 | depends=('gtk4' 'webkitgtk-6.0') | |
| 11 | 11 | optdepends=( | |
| 12 | 12 | 'gst-plugins-base: Base media codecs' | |
M cmd/systemviews/bootstrap.go +4 -1
| @@ -14,11 +14,12 @@ type bridgeServices interface { | |||
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 16 | func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App { | |
| 17 | + | route := systemviews.ParseRoute(locationURI) | |
| 17 | 18 | return systemviews.NewApp(systemviews.Dependencies{ | |
| 18 | 19 | DOM: dom, | |
| 19 | 20 | History: bridge, | |
| 20 | 21 | Favorites: bridge, | |
| 21 | - | Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)}, | |
| 22 | + | Config: bridgeConfigProxy{bridge: bridge, route: route}, | |
| 22 | 23 | LocationURI: locationURI, | |
| 23 | 24 | }) | |
| 24 | 25 | } | |
| @@ -41,6 +42,8 @@ func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error | |||
| 41 | 42 | } | |
| 42 | 43 | ||
| 43 | 44 | func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |
| 45 | + | // Keybindings are only rendered by dumb://config; skip the bridge load for | |
| 46 | + | // other systemview routes to keep initial history/favorites loads cheap. | |
| 44 | 47 | if p.route != systemviews.RouteConfig { | |
| 45 | 48 | return port.KeybindingsConfig{}, nil | |
| 46 | 49 | } | |
M cmd/systemviews/bootstrap_test.go +3 -2
| @@ -96,7 +96,8 @@ func (f *fakeDOM) Mount(html string) error { | |||
| 96 | 96 | return nil | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | - | // Handwritten fake to capture bridge state for stateful render assertions. | |
| 99 | + | // Handwritten fake intentionally tracks state across history, favorites, config, | |
| 100 | + | // and keybindings boundaries for the composite bootstrap assertions. | |
| 100 | 101 | type fakeBridgeService struct { | |
| 101 | 102 | calledHistory bool | |
| 102 | 103 | calledFavorites bool | |
| @@ -166,7 +167,7 @@ func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { retu | |||
| 166 | 167 | ||
| 167 | 168 | func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil } | |
| 168 | 169 | ||
| 169 | - | func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) { | |
| 170 | + | func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) { | |
| 170 | 171 | return nil, nil | |
| 171 | 172 | } | |
| 172 | 173 | ||
M cmd/systemviews/main.go +5 -2
| @@ -13,8 +13,11 @@ func main() { | |||
| 13 | 13 | bridge := systemviewsbridge.NewBrowserClient() | |
| 14 | 14 | href := "" | |
| 15 | 15 | location := js.Global().Get("location") | |
| 16 | - | if location.Type() != js.TypeUndefined && location.Type() != js.TypeNull { | |
| 17 | - | href = location.Get("href").String() | |
| 16 | + | if location.Truthy() { | |
| 17 | + | hrefValue := location.Get("href") | |
| 18 | + | if hrefValue.Truthy() && hrefValue.Type() == js.TypeString { | |
| 19 | + | href = hrefValue.String() | |
| 20 | + | } | |
| 18 | 21 | } | |
| 19 | 22 | app := newBridgeApp(systemviews.NewDOM(), href, bridge) | |
| 20 | 23 | ||
M docs/reference/keybindings.md +5 -5
| @@ -112,19 +112,19 @@ All keybindings can be customized in `~/.config/dumber/config.toml`: | |||
| 112 | 112 | split-right = ["arrowright", "r"] | |
| 113 | 113 | close-pane = ["x", "q"] | |
| 114 | 114 | ||
| 115 | - | [workspace.shortcuts.actions.close_pane] | |
| 115 | + | [workspace.shortcuts.actions.close-pane] | |
| 116 | 116 | keys = ["ctrl+w"] | |
| 117 | 117 | ||
| 118 | - | [workspace.shortcuts.actions.toggle_floating_pane] | |
| 118 | + | [workspace.shortcuts.actions.toggle-floating-pane] | |
| 119 | 119 | keys = ["alt+f"] | |
| 120 | 120 | ||
| 121 | - | [workspace.shortcuts.actions.toggle_history_systemview] | |
| 121 | + | [workspace.shortcuts.actions.toggle-history-systemview] | |
| 122 | 122 | keys = ["ctrl+h"] | |
| 123 | 123 | ||
| 124 | - | [workspace.shortcuts.actions.toggle_favorites_systemview] | |
| 124 | + | [workspace.shortcuts.actions.toggle-favorites-systemview] | |
| 125 | 125 | keys = [] | |
| 126 | 126 | ||
| 127 | - | [workspace.shortcuts.actions.toggle_config_systemview] | |
| 127 | + | [workspace.shortcuts.actions.toggle-config-systemview] | |
| 128 | 128 | keys = [] | |
| 129 | 129 | ||
| 130 | 130 | [workspace.floating_pane] | |
M go.mod +1 -1
| @@ -5,7 +5,7 @@ go 1.26.1 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/a-h/templ v0.3.1001 | |
| 7 | 7 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 8 | - | github.com/bnema/purego-cef v0.10.2 | |
| 8 | + | github.com/bnema/purego-cef v0.11.0 | |
| 9 | 9 | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 10 | 10 | github.com/bnema/purego-pipewire v0.1.2 | |
| 11 | 11 | github.com/bnema/purego-sqlite v0.1.2 | |
M go.sum +2 -0 generated file: collapsed
M internal/application/port/homepage.go +1 -1
| @@ -61,7 +61,7 @@ type HomepageFavorites interface { | |||
| 61 | 61 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | |
| 62 | 62 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | |
| 63 | 63 | GetAllFolders(ctx context.Context) ([]*entity.Folder, error) | |
| 64 | - | CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) | |
| 64 | + | CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) | |
| 65 | 65 | DeleteFolder(ctx context.Context, id entity.FolderID) error | |
| 66 | 66 | UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error | |
| 67 | 67 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | |
M internal/application/port/mocks/mocks.go +167 -14
| @@ -7240,8 +7240,8 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C | |||
| 7240 | 7240 | } | |
| 7241 | 7241 | ||
| 7242 | 7242 | // CreateFolder provides a mock function for the type MockHomepageFavorites | |
| 7243 | - | func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) { | |
| 7244 | - | ret := _mock.Called(ctx, name, parentID) | |
| 7243 | + | func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) { | |
| 7244 | + | ret := _mock.Called(ctx, name, icon, parentID) | |
| 7245 | 7245 | ||
| 7246 | 7246 | if len(ret) == 0 { | |
| 7247 | 7247 | panic("no return value specified for CreateFolder") | |
| @@ -7249,18 +7249,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin | |||
| 7249 | 7249 | ||
| 7250 | 7250 | var r0 *entity.Folder | |
| 7251 | 7251 | var r1 error | |
| 7252 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok { | |
| 7253 | - | return returnFunc(ctx, name, parentID) | |
| 7252 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok { | |
| 7253 | + | return returnFunc(ctx, name, icon, parentID) | |
| 7254 | 7254 | } | |
| 7255 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok { | |
| 7256 | - | r0 = returnFunc(ctx, name, parentID) | |
| 7255 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok { | |
| 7256 | + | r0 = returnFunc(ctx, name, icon, parentID) | |
| 7257 | 7257 | } else { | |
| 7258 | 7258 | if ret.Get(0) != nil { | |
| 7259 | 7259 | r0 = ret.Get(0).(*entity.Folder) | |
| 7260 | 7260 | } | |
| 7261 | 7261 | } | |
| 7262 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok { | |
| 7263 | - | r1 = returnFunc(ctx, name, parentID) | |
| 7262 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok { | |
| 7263 | + | r1 = returnFunc(ctx, name, icon, parentID) | |
| 7264 | 7264 | } else { | |
| 7265 | 7265 | r1 = ret.Error(1) | |
| 7266 | 7266 | } | |
| @@ -7275,12 +7275,13 @@ type MockHomepageFavorites_CreateFolder_Call struct { | |||
| 7275 | 7275 | // CreateFolder is a helper method to define mock.On call | |
| 7276 | 7276 | // - ctx context.Context | |
| 7277 | 7277 | // - name string | |
| 7278 | + | // - icon string | |
| 7278 | 7279 | // - parentID *entity.FolderID | |
| 7279 | - | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call { | |
| 7280 | - | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)} | |
| 7280 | + | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call { | |
| 7281 | + | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)} | |
| 7281 | 7282 | } | |
| 7282 | 7283 | ||
| 7283 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { | |
| 7284 | + | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { | |
| 7284 | 7285 | _c.Call.Run(func(args mock.Arguments) { | |
| 7285 | 7286 | var arg0 context.Context | |
| 7286 | 7287 | if args[0] != nil { | |
| @@ -7290,14 +7291,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont | |||
| 7290 | 7291 | if args[1] != nil { | |
| 7291 | 7292 | arg1 = args[1].(string) | |
| 7292 | 7293 | } | |
| 7293 | - | var arg2 *entity.FolderID | |
| 7294 | + | var arg2 string | |
| 7294 | 7295 | if args[2] != nil { | |
| 7295 | - | arg2 = args[2].(*entity.FolderID) | |
| 7296 | + | arg2 = args[2].(string) | |
| 7297 | + | } | |
| 7298 | + | var arg3 *entity.FolderID | |
| 7299 | + | if args[3] != nil { | |
| 7300 | + | arg3 = args[3].(*entity.FolderID) | |
| 7296 | 7301 | } | |
| 7297 | 7302 | run( | |
| 7298 | 7303 | arg0, | |
| 7299 | 7304 | arg1, | |
| 7300 | 7305 | arg2, | |
| 7306 | + | arg3, | |
| 7301 | 7307 | ) | |
| 7302 | 7308 | }) | |
| 7303 | 7309 | return _c | |
| @@ -7308,7 +7314,7 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder, | |||
| 7308 | 7314 | return _c | |
| 7309 | 7315 | } | |
| 7310 | 7316 | ||
| 7311 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call { | |
| 7317 | + | func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call { | |
| 7312 | 7318 | _c.Call.Return(run) | |
| 7313 | 7319 | return _c | |
| 7314 | 7320 | } | |
| @@ -12079,6 +12085,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context. | |||
| 12079 | 12085 | return _c | |
| 12080 | 12086 | } | |
| 12081 | 12087 | ||
| 12088 | + | // NewMockSystemviewConfigReader creates a new instance of MockSystemviewConfigReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12089 | + | // The first argument is typically a *testing.T value. | |
| 12090 | + | func NewMockSystemviewConfigReader(t interface { | |
| 12091 | + | mock.TestingT | |
| 12092 | + | Cleanup(func()) | |
| 12093 | + | }) *MockSystemviewConfigReader { | |
| 12094 | + | mock := &MockSystemviewConfigReader{} | |
| 12095 | + | mock.Mock.Test(t) | |
| 12096 | + | ||
| 12097 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 12098 | + | ||
| 12099 | + | return mock | |
| 12100 | + | } | |
| 12101 | + | ||
| 12102 | + | // MockSystemviewConfigReader is an autogenerated mock type for the SystemviewConfigReader type | |
| 12103 | + | type MockSystemviewConfigReader struct { | |
| 12104 | + | mock.Mock | |
| 12105 | + | } | |
| 12106 | + | ||
| 12107 | + | type MockSystemviewConfigReader_Expecter struct { | |
| 12108 | + | mock *mock.Mock | |
| 12109 | + | } | |
| 12110 | + | ||
| 12111 | + | func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expecter { | |
| 12112 | + | return &MockSystemviewConfigReader_Expecter{mock: &_m.Mock} | |
| 12113 | + | } | |
| 12114 | + | ||
| 12115 | + | // Current provides a mock function for the type MockSystemviewConfigReader | |
| 12116 | + | func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 12117 | + | ret := _mock.Called(ctx) | |
| 12118 | + | ||
| 12119 | + | if len(ret) == 0 { | |
| 12120 | + | panic("no return value specified for Current") | |
| 12121 | + | } | |
| 12122 | + | ||
| 12123 | + | var r0 port.SystemviewConfigPayload | |
| 12124 | + | var r1 error | |
| 12125 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok { | |
| 12126 | + | return returnFunc(ctx) | |
| 12127 | + | } | |
| 12128 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok { | |
| 12129 | + | r0 = returnFunc(ctx) | |
| 12130 | + | } else { | |
| 12131 | + | r0 = ret.Get(0).(port.SystemviewConfigPayload) | |
| 12132 | + | } | |
| 12133 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 12134 | + | r1 = returnFunc(ctx) | |
| 12135 | + | } else { | |
| 12136 | + | r1 = ret.Error(1) | |
| 12137 | + | } | |
| 12138 | + | return r0, r1 | |
| 12139 | + | } | |
| 12140 | + | ||
| 12141 | + | // MockSystemviewConfigReader_Current_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Current' | |
| 12142 | + | type MockSystemviewConfigReader_Current_Call struct { | |
| 12143 | + | *mock.Call | |
| 12144 | + | } | |
| 12145 | + | ||
| 12146 | + | // Current is a helper method to define mock.On call | |
| 12147 | + | // - ctx context.Context | |
| 12148 | + | func (_e *MockSystemviewConfigReader_Expecter) Current(ctx interface{}) *MockSystemviewConfigReader_Current_Call { | |
| 12149 | + | return &MockSystemviewConfigReader_Current_Call{Call: _e.mock.On("Current", ctx)} | |
| 12150 | + | } | |
| 12151 | + | ||
| 12152 | + | func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Current_Call { | |
| 12153 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 12154 | + | var arg0 context.Context | |
| 12155 | + | if args[0] != nil { | |
| 12156 | + | arg0 = args[0].(context.Context) | |
| 12157 | + | } | |
| 12158 | + | run( | |
| 12159 | + | arg0, | |
| 12160 | + | ) | |
| 12161 | + | }) | |
| 12162 | + | return _c | |
| 12163 | + | } | |
| 12164 | + | ||
| 12165 | + | func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call { | |
| 12166 | + | _c.Call.Return(systemviewConfigPayload, err) | |
| 12167 | + | return _c | |
| 12168 | + | } | |
| 12169 | + | ||
| 12170 | + | func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call { | |
| 12171 | + | _c.Call.Return(run) | |
| 12172 | + | return _c | |
| 12173 | + | } | |
| 12174 | + | ||
| 12175 | + | // Default provides a mock function for the type MockSystemviewConfigReader | |
| 12176 | + | func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 12177 | + | ret := _mock.Called(ctx) | |
| 12178 | + | ||
| 12179 | + | if len(ret) == 0 { | |
| 12180 | + | panic("no return value specified for Default") | |
| 12181 | + | } | |
| 12182 | + | ||
| 12183 | + | var r0 port.SystemviewConfigPayload | |
| 12184 | + | var r1 error | |
| 12185 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok { | |
| 12186 | + | return returnFunc(ctx) | |
| 12187 | + | } | |
| 12188 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok { | |
| 12189 | + | r0 = returnFunc(ctx) | |
| 12190 | + | } else { | |
| 12191 | + | r0 = ret.Get(0).(port.SystemviewConfigPayload) | |
| 12192 | + | } | |
| 12193 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 12194 | + | r1 = returnFunc(ctx) | |
| 12195 | + | } else { | |
| 12196 | + | r1 = ret.Error(1) | |
| 12197 | + | } | |
| 12198 | + | return r0, r1 | |
| 12199 | + | } | |
| 12200 | + | ||
| 12201 | + | // MockSystemviewConfigReader_Default_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Default' | |
| 12202 | + | type MockSystemviewConfigReader_Default_Call struct { | |
| 12203 | + | *mock.Call | |
| 12204 | + | } | |
| 12205 | + | ||
| 12206 | + | // Default is a helper method to define mock.On call | |
| 12207 | + | // - ctx context.Context | |
| 12208 | + | func (_e *MockSystemviewConfigReader_Expecter) Default(ctx interface{}) *MockSystemviewConfigReader_Default_Call { | |
| 12209 | + | return &MockSystemviewConfigReader_Default_Call{Call: _e.mock.On("Default", ctx)} | |
| 12210 | + | } | |
| 12211 | + | ||
| 12212 | + | func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Default_Call { | |
| 12213 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 12214 | + | var arg0 context.Context | |
| 12215 | + | if args[0] != nil { | |
| 12216 | + | arg0 = args[0].(context.Context) | |
| 12217 | + | } | |
| 12218 | + | run( | |
| 12219 | + | arg0, | |
| 12220 | + | ) | |
| 12221 | + | }) | |
| 12222 | + | return _c | |
| 12223 | + | } | |
| 12224 | + | ||
| 12225 | + | func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call { | |
| 12226 | + | _c.Call.Return(systemviewConfigPayload, err) | |
| 12227 | + | return _c | |
| 12228 | + | } | |
| 12229 | + | ||
| 12230 | + | func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call { | |
| 12231 | + | _c.Call.Return(run) | |
| 12232 | + | return _c | |
| 12233 | + | } | |
| 12234 | + | ||
| 12082 | 12235 | // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12083 | 12236 | // The first argument is typically a *testing.T value. | |
| 12084 | 12237 | func NewMockWebUIConfigSaver(t interface { | |
M internal/application/port/systemviews.go +1 -1
| @@ -28,7 +28,7 @@ type SystemviewFavoritesService interface { | |||
| 28 | 28 | ListTags(ctx context.Context) ([]*entity.Tag, error) | |
| 29 | 29 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | |
| 30 | 30 | SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error | |
| 31 | - | CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) | |
| 31 | + | CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) | |
| 32 | 32 | UpdateFolder(ctx context.Context, id int64, name, icon string) error | |
| 33 | 33 | DeleteFolder(ctx context.Context, id int64) error | |
| 34 | 34 | CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) | |
M internal/application/port/webui_config.go +1 -7
| @@ -12,13 +12,7 @@ type WebUIConfig struct { | |||
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | 14 | // SystemviewCustomPerformancePayload is the editable custom performance profile shape. | |
| 15 | - | type SystemviewCustomPerformancePayload struct { | |
| 16 | - | SkiaCPUThreads int `json:"skia_cpu_threads"` | |
| 17 | - | SkiaGPUThreads int `json:"skia_gpu_threads"` | |
| 18 | - | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |
| 19 | - | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |
| 20 | - | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |
| 21 | - | } | |
| 15 | + | type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig | |
| 22 | 16 | ||
| 23 | 17 | // SystemviewResolvedPerformancePayload is the resolved effective performance shape. | |
| 24 | 18 | type SystemviewResolvedPerformancePayload struct { | |
M internal/application/usecase/manage_favorites.go +96 -21
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "net/url" | |
| 6 | 7 | "sort" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| @@ -69,6 +70,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||
| 69 | 70 | return existing, nil | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 73 | + | return uc.addNewFavorite(ctx, input) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { | |
| 77 | + | log := logging.FromContext(ctx) | |
| 72 | 78 | fav := entity.NewFavorite(input.URL, input.Title) | |
| 73 | 79 | fav.FaviconURL = input.FaviconURL | |
| 74 | 80 | fav.FolderID = input.FolderID | |
| @@ -95,8 +101,14 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||
| 95 | 101 | ||
| 96 | 102 | // AddFavorite creates a favorite from the UI-facing application port. | |
| 97 | 103 | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | |
| 98 | - | if strings.TrimSpace(input.URL) == "" { | |
| 99 | - | return nil, fmt.Errorf("favorite URL is required") | |
| 104 | + | favoriteURL, err := normalizeFavoriteURL(input.URL) | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil { | |
| 109 | + | return nil, err | |
| 110 | + | } else if existing != nil { | |
| 111 | + | return existing, nil | |
| 100 | 112 | } | |
| 101 | 113 | tags := make([]entity.TagID, 0, len(input.Tags)) | |
| 102 | 114 | for _, tagID := range input.Tags { | |
| @@ -104,15 +116,66 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.Fa | |||
| 104 | 116 | tags = append(tags, tagID) | |
| 105 | 117 | } | |
| 106 | 118 | } | |
| 107 | - | return uc.Add(ctx, AddFavoriteInput{ | |
| 108 | - | URL: strings.TrimSpace(input.URL), | |
| 109 | - | Title: strings.TrimSpace(input.Title), | |
| 119 | + | title := strings.TrimSpace(input.Title) | |
| 120 | + | if title == "" { | |
| 121 | + | title = favoriteURL | |
| 122 | + | } | |
| 123 | + | return uc.addNewFavorite(ctx, AddFavoriteInput{ | |
| 124 | + | URL: favoriteURL, | |
| 125 | + | Title: title, | |
| 110 | 126 | FaviconURL: strings.TrimSpace(input.FaviconURL), | |
| 111 | 127 | FolderID: input.FolderID, | |
| 112 | 128 | Tags: tags, | |
| 113 | 129 | }) | |
| 114 | 130 | } | |
| 115 | 131 | ||
| 132 | + | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) { | |
| 133 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| 136 | + | } | |
| 137 | + | if existing != nil { | |
| 138 | + | return existing, nil | |
| 139 | + | } | |
| 140 | + | rawURL = strings.TrimSpace(rawURL) | |
| 141 | + | if rawURL == "" || rawURL == canonicalURL { | |
| 142 | + | return nil, nil | |
| 143 | + | } | |
| 144 | + | existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL) | |
| 145 | + | if err != nil { | |
| 146 | + | return nil, fmt.Errorf("failed to check legacy favorite: %w", err) | |
| 147 | + | } | |
| 148 | + | return existing, nil | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func normalizeFavoriteURL(raw string) (string, error) { | |
| 152 | + | favoriteURL := domainurl.Normalize(strings.TrimSpace(raw)) | |
| 153 | + | if favoriteURL == "" { | |
| 154 | + | return "", fmt.Errorf("favorite URL is required") | |
| 155 | + | } | |
| 156 | + | parsed, err := url.Parse(favoriteURL) | |
| 157 | + | if err != nil || parsed.Scheme == "" { | |
| 158 | + | return "", fmt.Errorf("favorite URL must be absolute") | |
| 159 | + | } | |
| 160 | + | switch strings.ToLower(parsed.Scheme) { | |
| 161 | + | case "http", "https": | |
| 162 | + | if parsed.Host == "" { | |
| 163 | + | return "", fmt.Errorf("favorite URL host is required") | |
| 164 | + | } | |
| 165 | + | case "dumb": | |
| 166 | + | if parsed.Host != "" { | |
| 167 | + | return favoriteURL, nil | |
| 168 | + | } | |
| 169 | + | if parsed.Opaque != "" { | |
| 170 | + | return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil | |
| 171 | + | } | |
| 172 | + | return "", fmt.Errorf("favorite URL host is required") | |
| 173 | + | default: | |
| 174 | + | return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") | |
| 175 | + | } | |
| 176 | + | return favoriteURL, nil | |
| 177 | + | } | |
| 178 | + | ||
| 116 | 179 | // UpdateFavorite updates editable favorite metadata from the UI-facing application port. | |
| 117 | 180 | func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 118 | 181 | if input.ID <= 0 { | |
| @@ -130,7 +193,11 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port | |||
| 130 | 193 | return nil, fmt.Errorf("favorite %d not found", input.ID) | |
| 131 | 194 | } | |
| 132 | 195 | ||
| 133 | - | fav.Title = strings.TrimSpace(input.Title) | |
| 196 | + | title := strings.TrimSpace(input.Title) | |
| 197 | + | if title == "" { | |
| 198 | + | return nil, fmt.Errorf("favorite title is required") | |
| 199 | + | } | |
| 200 | + | fav.Title = title | |
| 134 | 201 | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | |
| 135 | 202 | fav.FolderID = input.FolderID | |
| 136 | 203 | fav.ShortcutKey = input.ShortcutKey | |
| @@ -375,18 +442,20 @@ type ToggleResult struct { | |||
| 375 | 442 | ||
| 376 | 443 | // Toggle adds or removes a URL from favorites based on current state. | |
| 377 | 444 | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | |
| 378 | - | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | |
| 379 | - | log := logging.FromContext(ctx) | |
| 380 | - | log.Debug().Str("url", url).Msg("toggling favorite") | |
| 381 | - | ||
| 382 | - | if url == "" { | |
| 383 | - | return nil, fmt.Errorf("URL cannot be empty") | |
| 445 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) { | |
| 446 | + | favoriteURL, err := normalizeFavoriteURL(rawURL) | |
| 447 | + | if err != nil { | |
| 448 | + | return nil, err | |
| 384 | 449 | } | |
| 450 | + | title = strings.TrimSpace(title) | |
| 385 | 451 | ||
| 386 | - | // Check if already favorited | |
| 387 | - | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| 452 | + | log := logging.FromContext(ctx) | |
| 453 | + | log.Debug().Str("url", favoriteURL).Msg("toggling favorite") | |
| 454 | + | ||
| 455 | + | // Check if already favorited, including legacy rows stored before URL canonicalization. | |
| 456 | + | existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) | |
| 388 | 457 | if err != nil { | |
| 389 | - | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| 458 | + | return nil, err | |
| 390 | 459 | } | |
| 391 | 460 | ||
| 392 | 461 | if existing != nil { | |
| @@ -394,26 +463,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||
| 394 | 463 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |
| 395 | 464 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |
| 396 | 465 | } | |
| 397 | - | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 466 | + | log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 398 | 467 | uc.invalidateCache() | |
| 399 | 468 | return &ToggleResult{ | |
| 400 | 469 | Added: false, | |
| 401 | - | URL: url, | |
| 470 | + | URL: favoriteURL, | |
| 402 | 471 | Title: existing.Title, | |
| 403 | 472 | Message: "Favorite removed", | |
| 404 | 473 | }, nil | |
| 405 | 474 | } | |
| 406 | 475 | ||
| 407 | 476 | // Add favorite | |
| 408 | - | fav := entity.NewFavorite(url, title) | |
| 477 | + | fav := entity.NewFavorite(favoriteURL, title) | |
| 409 | 478 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |
| 410 | 479 | return nil, fmt.Errorf("failed to add favorite: %w", err) | |
| 411 | 480 | } | |
| 412 | - | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 481 | + | log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 413 | 482 | uc.invalidateCache() | |
| 414 | 483 | return &ToggleResult{ | |
| 415 | 484 | Added: true, | |
| 416 | - | URL: url, | |
| 485 | + | URL: favoriteURL, | |
| 417 | 486 | Title: title, | |
| 418 | 487 | Message: "Favorite added", | |
| 419 | 488 | }, nil | |
| @@ -509,11 +578,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite | |||
| 509 | 578 | } | |
| 510 | 579 | ||
| 511 | 580 | // CreateFolder creates a new folder. | |
| 512 | - | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) { | |
| 581 | + | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) { | |
| 582 | + | name = strings.TrimSpace(name) | |
| 583 | + | icon = strings.TrimSpace(icon) | |
| 584 | + | if name == "" { | |
| 585 | + | return nil, fmt.Errorf("folder name is required") | |
| 586 | + | } | |
| 513 | 587 | log := logging.FromContext(ctx) | |
| 514 | 588 | log.Debug().Str("name", name).Msg("creating folder") | |
| 515 | 589 | ||
| 516 | 590 | folder := entity.NewFolder(name) | |
| 591 | + | folder.Icon = icon | |
| 517 | 592 | folder.ParentID = parentID | |
| 518 | 593 | ||
| 519 | 594 | if err := uc.folderRepo.Save(ctx, folder); err != nil { | |
M internal/application/usecase/manage_favorites_test.go +92 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| @@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |||
| 142 | 143 | result, err := uc.Toggle(ctx, "", "Title") | |
| 143 | 144 | require.Error(t, err) | |
| 144 | 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 | 172 | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | |
| @@ -342,3 +366,70 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin | |||
| 342 | 366 | require.Len(t, favoritesAfter, 1) | |
| 343 | 367 | assert.Nil(t, favoritesAfter[0].FolderID) | |
| 344 | 368 | } | |
| 369 | + | ||
| 370 | + | func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | |
| 371 | + | ctx := testContext() | |
| 372 | + | ||
| 373 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 374 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 375 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 376 | + | ||
| 377 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |
| 378 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once() | |
| 379 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |
| 380 | + | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | |
| 381 | + | })).Return(nil).Once() | |
| 382 | + | ||
| 383 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 384 | + | ||
| 385 | + | fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "example.com", Title: " Example "}) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | require.NotNil(t, fav) | |
| 388 | + | assert.Equal(t, "https://example.com", fav.URL) | |
| 389 | + | assert.Equal(t, "Example", fav.Title) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | |
| 393 | + | ctx := testContext() | |
| 394 | + | ||
| 395 | + | tests := []string{ | |
| 396 | + | "", | |
| 397 | + | "not a url", | |
| 398 | + | "javascript:alert(1)", | |
| 399 | + | "data:text/html,<script>alert(1)</script>", | |
| 400 | + | "https:///missing-host", | |
| 401 | + | "dumb:///missing-host", | |
| 402 | + | } | |
| 403 | + | for _, raw := range tests { | |
| 404 | + | t.Run(raw, func(t *testing.T) { | |
| 405 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 406 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 407 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 408 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 409 | + | ||
| 410 | + | _, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: raw}) | |
| 411 | + | require.Error(t, err) | |
| 412 | + | }) | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) { | |
| 417 | + | ctx := testContext() | |
| 418 | + | ||
| 419 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 420 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 421 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 422 | + | ||
| 423 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once() | |
| 424 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once() | |
| 425 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |
| 426 | + | return fav != nil && fav.URL == "dumb://history" && fav.Title == "History" | |
| 427 | + | })).Return(nil).Once() | |
| 428 | + | ||
| 429 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 430 | + | ||
| 431 | + | fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "dumb:history", Title: "History"}) | |
| 432 | + | require.NoError(t, err) | |
| 433 | + | require.NotNil(t, fav) | |
| 434 | + | assert.Equal(t, "dumb://history", fav.URL) | |
| 435 | + | } | |
M internal/application/usecase/read_systemview_config.go +9 -3
| @@ -2,7 +2,7 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "fmt" | |
| 5 | + | "errors" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | ) | |
| @@ -14,13 +14,19 @@ type ReadSystemviewConfigUseCase struct { | |||
| 14 | 14 | ||
| 15 | 15 | // NewReadSystemviewConfigUseCase creates a new read systemview config use case. | |
| 16 | 16 | func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase { | |
| 17 | + | if reader == nil { | |
| 18 | + | panic("NewReadSystemviewConfigUseCase: reader is nil") | |
| 19 | + | } | |
| 17 | 20 | return &ReadSystemviewConfigUseCase{reader: reader} | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 23 | + | // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil. | |
| 24 | + | var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil") | |
| 25 | + | ||
| 20 | 26 | // Current returns the current systemview config payload. | |
| 21 | 27 | func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 22 | 28 | if uc == nil || uc.reader == nil { | |
| 23 | - | return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil") | |
| 29 | + | return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader | |
| 24 | 30 | } | |
| 25 | 31 | return uc.reader.Current(ctx) | |
| 26 | 32 | } | |
| @@ -28,7 +34,7 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.System | |||
| 28 | 34 | // Default returns the default systemview config payload. | |
| 29 | 35 | func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 30 | 36 | if uc == nil || uc.reader == nil { | |
| 31 | - | return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil") | |
| 37 | + | return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader | |
| 32 | 38 | } | |
| 33 | 39 | return uc.reader.Default(ctx) | |
| 34 | 40 | } | |
M internal/application/usecase/read_systemview_config_test.go +21 -22
| @@ -5,48 +5,47 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 8 | 10 | "github.com/stretchr/testify/require" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | func TestReadSystemviewConfigUseCase_Current(t *testing.T) { | |
| 12 | - | reader := &stubSystemviewConfigReader{ | |
| 13 | - | current: port.SystemviewConfigPayload{EngineType: "webkit"}, | |
| 14 | - | } | |
| 14 | + | reader := portmocks.NewMockSystemviewConfigReader(t) | |
| 15 | + | want := port.SystemviewConfigPayload{EngineType: "webkit"} | |
| 16 | + | reader.EXPECT().Current(mock.Anything).Return(want, nil).Once() | |
| 15 | 17 | ||
| 16 | 18 | uc := NewReadSystemviewConfigUseCase(reader) | |
| 17 | 19 | ||
| 18 | 20 | got, err := uc.Current(context.Background()) | |
| 19 | 21 | require.NoError(t, err) | |
| 20 | - | require.Equal(t, reader.current, got) | |
| 21 | - | require.Equal(t, []string{"current"}, reader.calls) | |
| 22 | + | require.Equal(t, want, got) | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | func TestReadSystemviewConfigUseCase_Default(t *testing.T) { | |
| 25 | - | reader := &stubSystemviewConfigReader{ | |
| 26 | - | defaultPayload: port.SystemviewConfigPayload{EngineType: "webkit"}, | |
| 27 | - | } | |
| 26 | + | reader := portmocks.NewMockSystemviewConfigReader(t) | |
| 27 | + | want := port.SystemviewConfigPayload{EngineType: "webkit"} | |
| 28 | + | reader.EXPECT().Default(mock.Anything).Return(want, nil).Once() | |
| 28 | 29 | ||
| 29 | 30 | uc := NewReadSystemviewConfigUseCase(reader) | |
| 30 | 31 | ||
| 31 | 32 | got, err := uc.Default(context.Background()) | |
| 32 | 33 | require.NoError(t, err) | |
| 33 | - | require.Equal(t, reader.defaultPayload, got) | |
| 34 | - | require.Equal(t, []string{"default"}, reader.calls) | |
| 34 | + | require.Equal(t, want, got) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | // Handwritten fake to capture stateful config reads for assertions. | |
| 38 | - | type stubSystemviewConfigReader struct { | |
| 39 | - | current port.SystemviewConfigPayload | |
| 40 | - | defaultPayload port.SystemviewConfigPayload | |
| 41 | - | calls []string | |
| 37 | + | func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) { | |
| 38 | + | require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() { | |
| 39 | + | NewReadSystemviewConfigUseCase(nil) | |
| 40 | + | }) | |
| 42 | 41 | } | |
| 43 | 42 | ||
| 44 | - | func (r *stubSystemviewConfigReader) Current(context.Context) (port.SystemviewConfigPayload, error) { | |
| 45 | - | r.calls = append(r.calls, "current") | |
| 46 | - | return r.current, nil | |
| 47 | - | } | |
| 43 | + | func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) { | |
| 44 | + | var uc *ReadSystemviewConfigUseCase | |
| 45 | + | ||
| 46 | + | _, err := uc.Current(context.Background()) | |
| 47 | + | require.ErrorIs(t, err, ErrNilSystemviewConfigReader) | |
| 48 | 48 | ||
| 49 | - | func (r *stubSystemviewConfigReader) Default(context.Context) (port.SystemviewConfigPayload, error) { | |
| 50 | - | r.calls = append(r.calls, "default") | |
| 51 | - | return r.defaultPayload, nil | |
| 49 | + | _, err = uc.Default(context.Background()) | |
| 50 | + | require.ErrorIs(t, err, ErrNilSystemviewConfigReader) | |
| 52 | 51 | } | |
M internal/application/usecase/save_webui_config.go +3 -2
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -55,8 +56,8 @@ func validateWebUIConfig(cfg port.WebUIConfig) error { | |||
| 55 | 56 | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | |
| 56 | 57 | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | |
| 57 | 58 | } | |
| 58 | - | if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { | |
| 59 | - | errs = append(errs, "default_ui_scale must be between 0.5 and 3.0") | |
| 59 | + | if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { | |
| 60 | + | errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0") | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 62 | 63 | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | |
M internal/application/usecase/save_webui_config_test.go +28 -22
| @@ -2,10 +2,13 @@ package usecase_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "math" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 8 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 9 | 12 | "github.com/stretchr/testify/require" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| @@ -36,13 +39,28 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) { | |||
| 36 | 39 | }, | |
| 37 | 40 | wantErr: "performance.profile", | |
| 38 | 41 | }, | |
| 42 | + | { | |
| 43 | + | name: "rejects NaN UI scale", | |
| 44 | + | mutate: func(cfg *port.WebUIConfig) { | |
| 45 | + | cfg.DefaultUIScale = math.NaN() | |
| 46 | + | }, | |
| 47 | + | wantErr: "default_ui_scale must be a finite value", | |
| 48 | + | }, | |
| 49 | + | { | |
| 50 | + | name: "rejects infinite UI scale", | |
| 51 | + | mutate: func(cfg *port.WebUIConfig) { | |
| 52 | + | cfg.DefaultUIScale = math.Inf(1) | |
| 53 | + | }, | |
| 54 | + | wantErr: "default_ui_scale must be a finite value", | |
| 55 | + | }, | |
| 39 | 56 | } | |
| 40 | 57 | ||
| 41 | 58 | for _, tt := range tests { | |
| 42 | 59 | t.Run(tt.name, func(t *testing.T) { | |
| 43 | 60 | cfg := validWebUIConfig() | |
| 44 | 61 | tt.mutate(&cfg) | |
| 45 | - | uc := usecase.NewSaveWebUIConfigUseCase(fakeWebUIConfigSaver{}) | |
| 62 | + | saver := portmocks.NewMockWebUIConfigSaver(t) | |
| 63 | + | uc := usecase.NewSaveWebUIConfigUseCase(saver) | |
| 46 | 64 | ||
| 47 | 65 | err := uc.Execute(context.Background(), cfg) | |
| 48 | 66 | ||
| @@ -53,7 +71,11 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) { | |||
| 53 | 71 | } | |
| 54 | 72 | ||
| 55 | 73 | func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | |
| 56 | - | saver := &capturingWebUIConfigSaver{} | |
| 74 | + | saver := portmocks.NewMockWebUIConfigSaver(t) | |
| 75 | + | var saved port.WebUIConfig | |
| 76 | + | saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("port.WebUIConfig")).Run(func(_ context.Context, cfg port.WebUIConfig) { | |
| 77 | + | saved = cfg | |
| 78 | + | }).Return(nil).Once() | |
| 57 | 79 | uc := usecase.NewSaveWebUIConfigUseCase(saver) | |
| 58 | 80 | cfg := validWebUIConfig() | |
| 59 | 81 | cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "} | |
| @@ -61,9 +83,10 @@ func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | |||
| 61 | 83 | err := uc.Execute(context.Background(), cfg) | |
| 62 | 84 | ||
| 63 | 85 | require.NoError(t, err) | |
| 64 | - | require.Contains(t, saver.saved.SearchShortcuts, "ddg") | |
| 65 | - | require.Equal(t, "https://duckduckgo.com/?q=%s", saver.saved.SearchShortcuts["ddg"].URL) | |
| 66 | - | require.Equal(t, "DuckDuckGo", saver.saved.SearchShortcuts["ddg"].Description) | |
| 86 | + | require.NotContains(t, saved.SearchShortcuts, " ddg ") | |
| 87 | + | require.Contains(t, saved.SearchShortcuts, "ddg") | |
| 88 | + | require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL) | |
| 89 | + | require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description) | |
| 67 | 90 | } | |
| 68 | 91 | ||
| 69 | 92 | func validWebUIConfig() port.WebUIConfig { | |
| @@ -94,20 +117,3 @@ func validWebUIConfig() port.WebUIConfig { | |||
| 94 | 117 | SearchShortcuts: map[string]port.SearchShortcut{}, | |
| 95 | 118 | } | |
| 96 | 119 | } | |
| 97 | - | ||
| 98 | - | // Handwritten fake to preserve stateful save assertions without mock generation. | |
| 99 | - | type fakeWebUIConfigSaver struct{} | |
| 100 | - | ||
| 101 | - | func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error { | |
| 102 | - | return nil | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Handwritten fake to capture the saved config state for assertions. | |
| 106 | - | type capturingWebUIConfigSaver struct { | |
| 107 | - | saved port.WebUIConfig | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | func (s *capturingWebUIConfigSaver) SaveWebUIConfig(_ context.Context, cfg port.WebUIConfig) error { | |
| 111 | - | s.saved = cfg | |
| 112 | - | return nil | |
| 113 | - | } | |
M internal/application/usecase/search_history.go +24 -8
| @@ -81,9 +81,9 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st | |||
| 81 | 81 | if limit <= 0 { | |
| 82 | 82 | limit = 50 | |
| 83 | 83 | } | |
| 84 | - | domain = domainurl.CanonicalDomain(domain) | |
| 85 | - | if domain == "" { | |
| 86 | - | return nil, fmt.Errorf("domain is required") | |
| 84 | + | domain, err := canonicalHistoryDomain(domain) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, err | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset) | |
| @@ -93,6 +93,16 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st | |||
| 93 | 93 | return entries, nil | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | // canonicalHistoryDomain normalizes a domain and rejects empty values before | |
| 97 | + | // the domain reaches exact-match persistence queries. | |
| 98 | + | func canonicalHistoryDomain(domain string) (string, error) { | |
| 99 | + | domain = domainurl.CanonicalDomain(domain) | |
| 100 | + | if domain == "" { | |
| 101 | + | return "", fmt.Errorf("domain is required") | |
| 102 | + | } | |
| 103 | + | return domain, nil | |
| 104 | + | } | |
| 105 | + | ||
| 96 | 106 | // GetRecentSince retrieves history entries visited within the last N days. | |
| 97 | 107 | // If days is 0, returns all history entries. | |
| 98 | 108 | // If days is negative, defaults to 30 days. | |
| @@ -173,7 +183,7 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time. | |||
| 173 | 183 | return nil | |
| 174 | 184 | } | |
| 175 | 185 | ||
| 176 | - | // ClearRange deletes history entries for a named range. | |
| 186 | + | // ClearRange deletes history entries inside a named recent range. | |
| 177 | 187 | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | |
| 178 | 188 | cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | |
| 179 | 189 | if !ok { | |
| @@ -183,7 +193,13 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) | |||
| 183 | 193 | return uc.ClearAll(ctx) | |
| 184 | 194 | } | |
| 185 | 195 | ||
| 186 | - | return uc.ClearOlderThan(ctx, cutoff) | |
| 196 | + | log := logging.FromContext(ctx) | |
| 197 | + | log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range") | |
| 198 | + | if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil { | |
| 199 | + | return fmt.Errorf("failed to clear history range: %w", err) | |
| 200 | + | } | |
| 201 | + | log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared") | |
| 202 | + | return nil | |
| 187 | 203 | } | |
| 188 | 204 | ||
| 189 | 205 | // ClearAll deletes all history entries. | |
| @@ -214,9 +230,9 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error { | |||
| 214 | 230 | ||
| 215 | 231 | // DeleteByDomain removes all history entries for a domain. | |
| 216 | 232 | func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error { | |
| 217 | - | domain = domainurl.CanonicalDomain(domain) | |
| 218 | - | if domain == "" { | |
| 219 | - | return fmt.Errorf("domain is required") | |
| 233 | + | domain, err := canonicalHistoryDomain(domain) | |
| 234 | + | if err != nil { | |
| 235 | + | return err | |
| 220 | 236 | } | |
| 221 | 237 | ||
| 222 | 238 | log := logging.FromContext(ctx) | |
M internal/application/usecase/search_history_test.go +57 -0
| @@ -152,6 +152,39 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin | |||
| 152 | 152 | assert.Empty(t, result) | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | + | func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) { | |
| 156 | + | ctx := testContext() | |
| 157 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 158 | + | historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool { | |
| 159 | + | return !since.IsZero() && time.Since(since) <= time.Hour+time.Minute | |
| 160 | + | })).Return(nil).Once() | |
| 161 | + | ||
| 162 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 163 | + | ||
| 164 | + | err := uc.ClearRange(ctx, "hour") | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) { | |
| 169 | + | ctx := testContext() | |
| 170 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 171 | + | today := time.Now() | |
| 172 | + | historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool { | |
| 173 | + | return since.Year() == today.Year() && | |
| 174 | + | since.Month() == today.Month() && | |
| 175 | + | since.Day() == today.Day() && | |
| 176 | + | since.Hour() == 0 && | |
| 177 | + | since.Minute() == 0 && | |
| 178 | + | since.Second() == 0 && | |
| 179 | + | since.Nanosecond() == 0 | |
| 180 | + | })).Return(nil).Once() | |
| 181 | + | ||
| 182 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 183 | + | ||
| 184 | + | err := uc.ClearRange(ctx, "day") | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | } | |
| 187 | + | ||
| 155 | 188 | func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) { | |
| 156 | 189 | ctx := testContext() | |
| 157 | 190 | ||
| @@ -176,3 +209,27 @@ func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) | |||
| 176 | 209 | require.Error(t, err) | |
| 177 | 210 | assert.Contains(t, err.Error(), "unknown history delete range") | |
| 178 | 211 | } | |
| 212 | + | ||
| 213 | + | func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) { | |
| 214 | + | ctx := testContext() | |
| 215 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 216 | + | want := []*entity.HistoryEntry{{URL: "https://example_.com"}} | |
| 217 | + | historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once() | |
| 218 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 219 | + | ||
| 220 | + | result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0) | |
| 221 | + | ||
| 222 | + | require.NoError(t, err) | |
| 223 | + | assert.Equal(t, want, result) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) { | |
| 227 | + | ctx := testContext() | |
| 228 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 229 | + | historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once() | |
| 230 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 231 | + | ||
| 232 | + | err := uc.DeleteByDomain(ctx, "example_.com") | |
| 233 | + | ||
| 234 | + | require.NoError(t, err) | |
| 235 | + | } | |
M internal/domain/history/delete_range.go +1 -1
| @@ -10,7 +10,7 @@ func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) { | |||
| 10 | 10 | case "hour": | |
| 11 | 11 | return now.Add(-time.Hour), false, true | |
| 12 | 12 | case "day": | |
| 13 | - | return now.AddDate(0, 0, -1), false, true | |
| 13 | + | return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), false, true | |
| 14 | 14 | case "week": | |
| 15 | 15 | return now.AddDate(0, 0, -7), false, true | |
| 16 | 16 | case "month": | |
M internal/domain/history/delete_range_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestDeleteRangeCutoff(t *testing.T) { | |||
| 16 | 16 | wantOK bool | |
| 17 | 17 | }{ | |
| 18 | 18 | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true}, | |
| 19 | - | {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true}, | |
| 19 | + | {name: "day", rangeID: "day", want: time.Date(2026, time.April, 11, 0, 0, 0, 0, time.UTC), wantAll: false, wantOK: true}, | |
| 20 | 20 | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true}, | |
| 21 | 21 | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true}, | |
| 22 | 22 | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true}, | |
M internal/domain/repository/history.go +3 -0
| @@ -47,6 +47,9 @@ type HistoryRepository interface { | |||
| 47 | 47 | // DeleteOlderThan removes entries older than the given time. | |
| 48 | 48 | DeleteOlderThan(ctx context.Context, before time.Time) error | |
| 49 | 49 | ||
| 50 | + | // DeleteSince removes entries visited at or after the given time. | |
| 51 | + | DeleteSince(ctx context.Context, since time.Time) error | |
| 52 | + | ||
| 50 | 53 | // DeleteAll removes all history entries. | |
| 51 | 54 | DeleteAll(ctx context.Context) error | |
| 52 | 55 | ||
M internal/domain/repository/mocks/mocks.go +57 -0
| @@ -2129,6 +2129,63 @@ func (_c *MockHistoryRepository_DeleteOlderThan_Call) RunAndReturn(run func(ctx | |||
| 2129 | 2129 | 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 | 2189 | // FindByURL provides a mock function for the type MockHistoryRepository | |
| 2133 | 2190 | func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | |
| 2134 | 2191 | ret := _mock.Called(ctx, url) | |
M internal/domain/url/normalize.go +2 -4
| @@ -192,10 +192,8 @@ func CanonicalDomain(raw string) string { | |||
| 192 | 192 | if domain := ExtractDomain(raw); domain != "" { | |
| 193 | 193 | return domain | |
| 194 | 194 | } | |
| 195 | - | for _, sep := range []string{"/", "?", "#"} { | |
| 196 | - | if before, _, ok := strings.Cut(raw, sep); ok { | |
| 197 | - | raw = before | |
| 198 | - | } | |
| 195 | + | if idx := strings.IndexAny(raw, "/?#"); idx >= 0 { | |
| 196 | + | raw = raw[:idx] | |
| 199 | 197 | } | |
| 200 | 198 | return strings.TrimPrefix(raw, "www.") | |
| 201 | 199 | } | |
M internal/domain/url/normalize_test.go +28 -2
| @@ -699,8 +699,12 @@ func TestCanonicalDomain(t *testing.T) { | |||
| 699 | 699 | input string | |
| 700 | 700 | want string | |
| 701 | 701 | }{ | |
| 702 | + | {name: "empty", input: "", want: ""}, | |
| 703 | + | {name: "whitespace only", input: " ", want: ""}, | |
| 702 | 704 | {name: "url", input: "https://www.example.com/path", want: "example.com"}, | |
| 703 | 705 | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"}, | |
| 706 | + | {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"}, | |
| 707 | + | {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"}, | |
| 704 | 708 | {name: "domain", input: "www.example.com", want: "example.com"}, | |
| 705 | 709 | {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"}, | |
| 706 | 710 | } | |
| @@ -715,7 +719,29 @@ func TestCanonicalDomain(t *testing.T) { | |||
| 715 | 719 | } | |
| 716 | 720 | ||
| 717 | 721 | func TestDisplayDomain(t *testing.T) { | |
| 718 | - | if got := DisplayDomain("https://www.example.com:8443/path"); got != "example.com" { | |
| 719 | - | t.Errorf("DisplayDomain() = %q, want %q", got, "example.com") | |
| 722 | + | tests := []struct { | |
| 723 | + | name string | |
| 724 | + | input string | |
| 725 | + | want string | |
| 726 | + | }{ | |
| 727 | + | {name: "empty", input: "", want: ""}, | |
| 728 | + | {name: "whitespace only", input: " ", want: ""}, | |
| 729 | + | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"}, | |
| 730 | + | {name: "url without port", input: "https://www.example.com/path", want: "example.com"}, | |
| 731 | + | {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"}, | |
| 732 | + | {name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"}, | |
| 733 | + | {name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"}, | |
| 734 | + | {name: "invalid url", input: "%zz", want: "%zz"}, | |
| 735 | + | {name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"}, | |
| 736 | + | {name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"}, | |
| 737 | + | {name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"}, | |
| 738 | + | } | |
| 739 | + | ||
| 740 | + | for _, tt := range tests { | |
| 741 | + | t.Run(tt.name, func(t *testing.T) { | |
| 742 | + | if got := DisplayDomain(tt.input); got != tt.want { | |
| 743 | + | t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want) | |
| 744 | + | } | |
| 745 | + | }) | |
| 720 | 746 | } | |
| 721 | 747 | } | |
M internal/infrastructure/cef/scheme_handler.go +101 -7
| @@ -1,24 +1,29 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "embed" | |
| 6 | 7 | "encoding/base64" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "html" | |
| 11 | + | "io" | |
| 10 | 12 | "io/fs" | |
| 13 | + | "net" | |
| 11 | 14 | "net/http" | |
| 12 | 15 | "net/url" | |
| 13 | - | "path/filepath" | |
| 16 | + | "path" | |
| 14 | 17 | "strings" | |
| 15 | 18 | "sync" | |
| 16 | 19 | "time" | |
| 17 | 20 | "unsafe" | |
| 18 | 21 | ||
| 22 | + | "github.com/andybalholm/brotli" | |
| 19 | 23 | purecef "github.com/bnema/purego-cef/cef" | |
| 20 | 24 | ||
| 21 | 25 | "github.com/bnema/dumber/internal/application/port" | |
| 26 | + | "github.com/bnema/dumber/internal/infrastructure/netguard" | |
| 22 | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 23 | 28 | "github.com/bnema/dumber/internal/logging" | |
| 24 | 29 | "github.com/rs/zerolog" | |
| @@ -32,6 +37,8 @@ const ( | |||
| 32 | 37 | errorPath = "error" | |
| 33 | 38 | indexHTML = "index.html" | |
| 34 | 39 | maxSchemeTruncatedURLLength = 240 | |
| 40 | + | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | |
| 41 | + | systemviewsAssetDir = "systemviews" | |
| 35 | 42 | ) | |
| 36 | 43 | ||
| 37 | 44 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | |
| @@ -100,7 +107,7 @@ func newDumbSchemeHandler( | |||
| 100 | 107 | ctx: ctx, | |
| 101 | 108 | messageRouter: router, | |
| 102 | 109 | transcoder: transcoder, | |
| 103 | - | assetDir: "systemviews", | |
| 110 | + | assetDir: systemviewsAssetDir, | |
| 104 | 111 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 105 | 112 | currentConfigPayload: currentConfigPayload, | |
| 106 | 113 | defaultConfigPayload: defaultConfigPayload, | |
| @@ -190,8 +197,14 @@ func isAPIPostMethod(method string) bool { | |||
| 190 | 197 | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | |
| 191 | 198 | switch path { | |
| 192 | 199 | case "/api/config": | |
| 200 | + | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |
| 201 | + | return denied, true | |
| 202 | + | } | |
| 193 | 203 | return h.handleConfigAPI(h.currentConfigPayload), true | |
| 194 | 204 | case "/api/config/default": | |
| 205 | + | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |
| 206 | + | return denied, true | |
| 207 | + | } | |
| 195 | 208 | return h.handleConfigAPI(h.defaultConfigPayload), true | |
| 196 | 209 | case "/api/transcode": | |
| 197 | 210 | return h.handleTranscodeAPI(request), true | |
| @@ -224,6 +237,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, | |||
| 224 | 237 | } | |
| 225 | 238 | ||
| 226 | 239 | func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler { | |
| 240 | + | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |
| 241 | + | return denied | |
| 242 | + | } | |
| 227 | 243 | body := readBodyFromHeader(request) | |
| 228 | 244 | if body == nil { | |
| 229 | 245 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| @@ -235,6 +251,14 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re | |||
| 235 | 251 | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | |
| 236 | 252 | } | |
| 237 | 253 | ||
| 254 | + | func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler { | |
| 255 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |
| 256 | + | if origin == "" || strings.EqualFold(origin, actualInternalOrigin) { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"}) | |
| 260 | + | } | |
| 261 | + | ||
| 238 | 262 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |
| 239 | 263 | if h.transcoder == nil { | |
| 240 | 264 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |
| @@ -255,6 +279,9 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 255 | 279 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | |
| 256 | 280 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |
| 257 | 281 | } | |
| 282 | + | if err := validateTranscodeSourceURL(h.ctx, sourceParsed); err != nil { | |
| 283 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": err.Error()}) | |
| 284 | + | } | |
| 258 | 285 | ||
| 259 | 286 | headers := make(map[string]string) | |
| 260 | 287 | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |
| @@ -287,6 +314,39 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 287 | 314 | }) | |
| 288 | 315 | } | |
| 289 | 316 | ||
| 317 | + | func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error { | |
| 318 | + | if source == nil || source.Host == "" { | |
| 319 | + | return fmt.Errorf("invalid src") | |
| 320 | + | } | |
| 321 | + | host := source.Hostname() | |
| 322 | + | if host == "" { | |
| 323 | + | return fmt.Errorf("invalid src host") | |
| 324 | + | } | |
| 325 | + | if ip := net.ParseIP(host); ip != nil { | |
| 326 | + | if isBlockedTranscodeIP(ip) { | |
| 327 | + | return fmt.Errorf("private src host not allowed") | |
| 328 | + | } | |
| 329 | + | return nil | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |
| 333 | + | defer cancel() | |
| 334 | + | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |
| 335 | + | if err != nil || len(addrs) == 0 { | |
| 336 | + | return fmt.Errorf("src host could not be resolved") | |
| 337 | + | } | |
| 338 | + | for _, addr := range addrs { | |
| 339 | + | if isBlockedTranscodeIP(addr.IP) { | |
| 340 | + | return fmt.Errorf("private src host not allowed") | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | return nil | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func isBlockedTranscodeIP(ip net.IP) bool { | |
| 347 | + | return netguard.IsBlockedTranscodeIP(ip) | |
| 348 | + | } | |
| 349 | + | ||
| 290 | 350 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |
| 291 | 351 | if build == nil { | |
| 292 | 352 | return nil, fmt.Errorf("config payload builder not configured") | |
| @@ -499,7 +559,11 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 499 | 559 | assetDir = h.assetDir | |
| 500 | 560 | } | |
| 501 | 561 | ||
| 502 | - | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |
| 562 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath) | |
| 563 | + | if !ok { | |
| 564 | + | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | |
| 565 | + | } | |
| 566 | + | ||
| 503 | 567 | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | |
| 504 | 568 | if err != nil { | |
| 505 | 569 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| @@ -516,10 +580,40 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 516 | 580 | return newStaticResourceHandler(http.StatusOK, contentType, data, headers) | |
| 517 | 581 | } | |
| 518 | 582 | ||
| 583 | + | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |
| 584 | + | assetDir = strings.Trim(assetDir, "/") | |
| 585 | + | if assetDir != systemviewsAssetDir { | |
| 586 | + | return "", "", false | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | relPath = strings.TrimLeft(relPath, "/") | |
| 590 | + | if relPath == "" || strings.ContainsRune(relPath, '\x00') { | |
| 591 | + | return "", "", false | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | cleanRelPath = path.Clean(relPath) | |
| 595 | + | if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) { | |
| 596 | + | return "", "", false | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | fullPath = path.Join(assetDir, cleanRelPath) | |
| 600 | + | if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") { | |
| 601 | + | return "", "", false | |
| 602 | + | } | |
| 603 | + | return fullPath, cleanRelPath, true | |
| 604 | + | } | |
| 605 | + | ||
| 519 | 606 | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | |
| 520 | 607 | if strings.HasSuffix(relPath, ".wasm") { | |
| 521 | - | if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| 522 | - | return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil | |
| 608 | + | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| 609 | + | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |
| 610 | + | if err != nil { | |
| 611 | + | return nil, nil, err | |
| 612 | + | } | |
| 613 | + | if len(data) > maxSystemviewsWASMBytes { | |
| 614 | + | return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |
| 615 | + | } | |
| 616 | + | return data, nil, nil | |
| 523 | 617 | } | |
| 524 | 618 | } | |
| 525 | 619 | data, err := fs.ReadFile(assets, fullPath) | |
| @@ -591,7 +685,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 591 | 685 | } | |
| 592 | 686 | ||
| 593 | 687 | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | |
| 594 | - | return "systemviews", path, true | |
| 688 | + | return systemviewsAssetDir, path, true | |
| 595 | 689 | } | |
| 596 | 690 | ||
| 597 | 691 | return "", "", false | |
| @@ -600,7 +694,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 600 | 694 | func assetDirForPageHost(host string) string { | |
| 601 | 695 | switch host { | |
| 602 | 696 | case historyPath, favoritesPath, configPath, errorPath: | |
| 603 | - | return "systemviews" | |
| 697 | + | return systemviewsAssetDir | |
| 604 | 698 | default: | |
| 605 | 699 | return "" | |
| 606 | 700 | } | |
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +49 -0
| @@ -3,6 +3,8 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "net/url" | |
| 5 | 5 | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/assets" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |
| @@ -40,6 +42,21 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||
| 40 | 42 | } | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 45 | + | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | |
| 46 | + | t.Parallel() | |
| 47 | + | ||
| 48 | + | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatalf("readAssetWithEncoding() error = %v", err) | |
| 51 | + | } | |
| 52 | + | if len(data) < 4 || string(data[:4]) != "\x00asm" { | |
| 53 | + | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | |
| 54 | + | } | |
| 55 | + | if _, ok := headers["Content-Encoding"]; ok { | |
| 56 | + | t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"]) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 43 | 60 | func mustParseURL(t *testing.T, raw string) *url.URL { | |
| 44 | 61 | t.Helper() | |
| 45 | 62 | u, err := url.Parse(raw) | |
| @@ -48,3 +65,35 @@ func mustParseURL(t *testing.T, raw string) *url.URL { | |||
| 48 | 65 | } | |
| 49 | 66 | return u | |
| 50 | 67 | } | |
| 68 | + | ||
| 69 | + | func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) { | |
| 70 | + | t.Parallel() | |
| 71 | + | ||
| 72 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css") | |
| 73 | + | if !ok { | |
| 74 | + | t.Fatal("safeSystemviewsAssetPath() rejected safe cleaned path") | |
| 75 | + | } | |
| 76 | + | if fullPath != "systemviews/systemviews.css" || relPath != "systemviews.css" { | |
| 77 | + | t.Fatalf("safeSystemviewsAssetPath() = (%q, %q), want (systemviews/systemviews.css, systemviews.css)", fullPath, relPath) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | invalid := []struct { | |
| 81 | + | name string | |
| 82 | + | assetDir string | |
| 83 | + | relPath string | |
| 84 | + | }{ | |
| 85 | + | {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"}, | |
| 86 | + | {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"}, | |
| 87 | + | {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"}, | |
| 88 | + | {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"}, | |
| 89 | + | {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"}, | |
| 90 | + | } | |
| 91 | + | for _, tt := range invalid { | |
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |
| 93 | + | t.Parallel() | |
| 94 | + | if fullPath, relPath, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath); ok { | |
| 95 | + | t.Fatalf("safeSystemviewsAssetPath(%q, %q) = (%q, %q, true), want rejected", tt.assetDir, tt.relPath, fullPath, relPath) | |
| 96 | + | } | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | } | |
M internal/infrastructure/cef/scheme_handler_test.go +53 -0
| @@ -4,7 +4,9 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "errors" | |
| 7 | + | "net" | |
| 7 | 8 | "net/http" | |
| 9 | + | "net/url" | |
| 8 | 10 | "testing" | |
| 9 | 11 | ||
| 10 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -60,6 +62,57 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||
| 60 | 62 | require.Contains(t, err.Error(), "default config payload builder") | |
| 61 | 63 | } | |
| 62 | 64 | ||
| 65 | + | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | |
| 66 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | ||
| 69 | + | request := cefmocks.NewMockRequest(t) | |
| 70 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |
| 71 | + | ||
| 72 | + | require.Nil(t, h.rejectForbiddenAPIOrigin(request)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |
| 76 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 77 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 78 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 79 | + | ||
| 80 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | ||
| 83 | + | request := cefmocks.NewMockRequest(t) | |
| 84 | + | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |
| 85 | + | ||
| 86 | + | response := cefmocks.NewMockResponse(t) | |
| 87 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |
| 88 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |
| 89 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 90 | + | expectAPIResponseHeaders(response) | |
| 91 | + | ||
| 92 | + | handler := h.rejectForbiddenAPIOrigin(request) | |
| 93 | + | require.NotNil(t, handler) | |
| 94 | + | var responseLength int64 | |
| 95 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |
| 96 | + | require.Positive(t, responseLength) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |
| 100 | + | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | ||
| 103 | + | err = validateTranscodeSourceURL(context.Background(), privateURL) | |
| 104 | + | require.Error(t, err) | |
| 105 | + | require.Contains(t, err.Error(), "private src host") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |
| 109 | + | for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} { | |
| 110 | + | t.Run(rawIP, func(t *testing.T) { | |
| 111 | + | require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP))) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 63 | 116 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |
| 64 | 117 | request := cefmocks.NewMockRequest(t) | |
| 65 | 118 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
M internal/infrastructure/config/defaults.go +11 -11
| @@ -238,17 +238,17 @@ func DefaultConfig() *Config { | |||
| 238 | 238 | }, | |
| 239 | 239 | Shortcuts: GlobalShortcutsConfig{ | |
| 240 | 240 | Actions: map[string]ActionBinding{ | |
| 241 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | |
| 242 | - | "toggle_history_systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, | |
| 243 | - | "toggle_favorites_systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"}, | |
| 244 | - | "toggle_config_systemview": {Keys: []string{}, Desc: "Toggle Config in right split"}, | |
| 245 | - | "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, | |
| 246 | - | "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, | |
| 247 | - | "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | |
| 248 | - | "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, | |
| 249 | - | "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | |
| 250 | - | "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | |
| 251 | - | "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | |
| 241 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | |
| 242 | + | "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, | |
| 243 | + | "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"}, | |
| 244 | + | "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"}, | |
| 245 | + | "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, | |
| 246 | + | "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, | |
| 247 | + | "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | |
| 248 | + | "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, | |
| 249 | + | "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | |
| 250 | + | "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | |
| 251 | + | "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | |
| 252 | 252 | }, | |
| 253 | 253 | }, | |
| 254 | 254 | FloatingPane: FloatingPaneConfig{ | |
M internal/infrastructure/config/defaults_test.go +3 -3
| @@ -28,9 +28,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||
| 28 | 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | |
| 29 | 29 | ||
| 30 | 30 | // System view shortcuts are first-class global actions. | |
| 31 | - | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_history_systemview", []string{"ctrl+h"}) | |
| 32 | - | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_favorites_systemview", []string{}) | |
| 33 | - | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_config_systemview", []string{}) | |
| 31 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"}) | |
| 32 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{}) | |
| 33 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{}) | |
| 34 | 34 | ||
| 35 | 35 | // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config. | |
| 36 | 36 | // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above). | |
M internal/infrastructure/config/floating_pane_config_test.go +2 -2
| @@ -15,7 +15,7 @@ func TestFloatingPaneConfig_Defaults(t *testing.T) { | |||
| 15 | 15 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | |
| 16 | 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 | 19 | require.True(t, ok) | |
| 20 | 20 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | |
| 21 | 21 | } | |
| @@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) { | |||
| 31 | 31 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | |
| 32 | 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 | 35 | require.True(t, ok) | |
| 36 | 36 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | |
| 37 | 37 | } | |
M internal/infrastructure/config/migrate_test.go +4 -4
| @@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) { | |||
| 695 | 695 | for actionName, value := range defaultActions { | |
| 696 | 696 | userActions[actionName] = value | |
| 697 | 697 | } | |
| 698 | - | delete(userActions, "toggle_floating_pane") | |
| 698 | + | delete(userActions, "toggle-floating-pane") | |
| 699 | 699 | ||
| 700 | 700 | missing := m.detectMissingWorkspaceShortcutActions(map[string]any{ | |
| 701 | 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 | 707 | func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |
| @@ -750,7 +750,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |||
| 750 | 750 | actions, ok := actionsAny.(map[string]any) | |
| 751 | 751 | require.True(t, ok) | |
| 752 | 752 | ||
| 753 | - | toggleAny, hasToggle := actions["toggle_floating_pane"] | |
| 753 | + | toggleAny, hasToggle := actions["toggle-floating-pane"] | |
| 754 | 754 | assert.True(t, hasToggle) | |
| 755 | 755 | assert.NotNil(t, toggleAny) | |
| 756 | 756 | ||
| @@ -764,7 +764,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |||
| 764 | 764 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | |
| 765 | 765 | m := NewMigrator() | |
| 766 | 766 | ||
| 767 | - | value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane") | |
| 767 | + | value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane") | |
| 768 | 768 | ||
| 769 | 769 | action, ok := value.(ActionBinding) | |
| 770 | 770 | require.True(t, ok) | |
M internal/infrastructure/config/webui_payload.go +22 -18
| @@ -45,31 +45,35 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste | |||
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig { | |
| 48 | + | defaults := DefaultConfig().Appearance | |
| 48 | 49 | return port.WebUIAppearanceConfig{ | |
| 49 | 50 | SansFont: appearance.SansFont, | |
| 50 | 51 | SerifFont: appearance.SerifFont, | |
| 51 | 52 | MonospaceFont: appearance.MonospaceFont, | |
| 52 | 53 | DefaultFontSize: appearance.DefaultFontSize, | |
| 53 | 54 | ColorScheme: appearance.ColorScheme, | |
| 54 | - | LightPalette: port.ColorPalette{ | |
| 55 | - | Background: appearance.LightPalette.Background, | |
| 56 | - | Surface: appearance.LightPalette.Surface, | |
| 57 | - | SurfaceVariant: appearance.LightPalette.SurfaceVariant, | |
| 58 | - | Text: appearance.LightPalette.Text, | |
| 59 | - | Muted: appearance.LightPalette.Muted, | |
| 60 | - | Accent: appearance.LightPalette.Accent, | |
| 61 | - | Border: appearance.LightPalette.Border, | |
| 62 | - | }, | |
| 63 | - | DarkPalette: port.ColorPalette{ | |
| 64 | - | Background: appearance.DarkPalette.Background, | |
| 65 | - | Surface: appearance.DarkPalette.Surface, | |
| 66 | - | SurfaceVariant: appearance.DarkPalette.SurfaceVariant, | |
| 67 | - | Text: appearance.DarkPalette.Text, | |
| 68 | - | Muted: appearance.DarkPalette.Muted, | |
| 69 | - | Accent: appearance.DarkPalette.Accent, | |
| 70 | - | Border: appearance.DarkPalette.Border, | |
| 71 | - | }, | |
| 55 | + | LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette), | |
| 56 | + | DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette), | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func buildSystemviewPalettePayload(palette, fallback ColorPalette) port.ColorPalette { | |
| 61 | + | return port.ColorPalette{ | |
| 62 | + | Background: nonEmptyColor(palette.Background, fallback.Background), | |
| 63 | + | Surface: nonEmptyColor(palette.Surface, fallback.Surface), | |
| 64 | + | SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant), | |
| 65 | + | Text: nonEmptyColor(palette.Text, fallback.Text), | |
| 66 | + | Muted: nonEmptyColor(palette.Muted, fallback.Muted), | |
| 67 | + | Accent: nonEmptyColor(palette.Accent, fallback.Accent), | |
| 68 | + | Border: nonEmptyColor(palette.Border, fallback.Border), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func nonEmptyColor(value, fallback string) string { | |
| 73 | + | if value != "" { | |
| 74 | + | return value | |
| 72 | 75 | } | |
| 76 | + | return fallback | |
| 73 | 77 | } | |
| 74 | 78 | ||
| 75 | 79 | func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut { | |
M internal/infrastructure/config/webui_payload_test.go +12 -0
| @@ -13,3 +13,15 @@ func TestBuildSystemviewConfigPayload_EmptySearchShortcutsUsesEmptyMap(t *testin | |||
| 13 | 13 | require.NotNil(t, payload.SearchShortcuts) | |
| 14 | 14 | require.Empty(t, payload.SearchShortcuts) | |
| 15 | 15 | } | |
| 16 | + | ||
| 17 | + | func TestBuildSystemviewConfigPayload_FillsMissingPaletteValuesFromDefaults(t *testing.T) { | |
| 18 | + | cfg := DefaultConfig() | |
| 19 | + | cfg.Appearance.LightPalette.Background = "" | |
| 20 | + | cfg.Appearance.DarkPalette.Accent = "" | |
| 21 | + | ||
| 22 | + | payload := BuildSystemviewConfigPayload(cfg, nil) | |
| 23 | + | defaults := DefaultConfig() | |
| 24 | + | ||
| 25 | + | require.Equal(t, defaults.Appearance.LightPalette.Background, payload.Appearance.LightPalette.Background) | |
| 26 | + | require.Equal(t, defaults.Appearance.DarkPalette.Accent, payload.Appearance.DarkPalette.Accent) | |
| 27 | + | } | |
M internal/infrastructure/handlers/config.go +6 -2
| @@ -24,7 +24,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | // Handle processes the save_config message. | |
| 27 | - | func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 27 | + | func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, payload json.RawMessage) (any, error) { | |
| 28 | 28 | if h == nil { | |
| 29 | 29 | return nil, fmt.Errorf("config handler is nil") | |
| 30 | 30 | } | |
| @@ -39,7 +39,11 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js | |||
| 39 | 39 | return nil, fmt.Errorf("invalid config format: %w", err) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | log.Info().Msg("saving appearance configuration") | |
| 42 | + | source := "webui" | |
| 43 | + | if viewID != 0 { | |
| 44 | + | source = fmt.Sprintf("webview:%d", viewID) | |
| 45 | + | } | |
| 46 | + | log.Info().Str("source", source).Msg("saving appearance configuration") | |
| 43 | 47 | ||
| 44 | 48 | if err := h.saveConfig(ctx, payloadCfg); err != nil { | |
| 45 | 49 | log.Error().Err(err).Msg("failed to save config") | |
M internal/infrastructure/handlers/homepage/favorites.go +33 -25
| @@ -3,12 +3,32 @@ package homepage | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 14 | + | func folderIDFromInt64(id *int64) *entity.FolderID { | |
| 15 | + | if id == nil { | |
| 16 | + | return nil | |
| 17 | + | } | |
| 18 | + | folderID := entity.FolderID(*id) | |
| 19 | + | return &folderID | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func tagIDsFromInt64s(ids []int64) []entity.TagID { | |
| 23 | + | out := make([]entity.TagID, 0, len(ids)) | |
| 24 | + | for _, id := range ids { | |
| 25 | + | if id > 0 { | |
| 26 | + | out = append(out, entity.TagID(id)) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | return out | |
| 30 | + | } | |
| 31 | + | ||
| 12 | 32 | // FavoritesHandlers handles favorite-related messages from the homepage. | |
| 13 | 33 | type FavoritesHandlers struct { | |
| 14 | 34 | favoritesUC port.HomepageFavorites | |
| @@ -63,6 +83,10 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |||
| 63 | 83 | Str("url", req.URL). | |
| 64 | 84 | Msg("handling favorite_create") | |
| 65 | 85 | ||
| 86 | + | if strings.TrimSpace(req.URL) == "" { | |
| 87 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil | |
| 88 | + | } | |
| 89 | + | ||
| 66 | 90 | input := port.FavoriteCreateInput{ | |
| 67 | 91 | URL: req.URL, | |
| 68 | 92 | Title: req.Title, | |
| @@ -103,6 +127,10 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |||
| 103 | 127 | Int64("favorite_id", req.ID). | |
| 104 | 128 | Msg("handling favorite_update") | |
| 105 | 129 | ||
| 130 | + | if req.ID <= 0 { | |
| 131 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | |
| 132 | + | } | |
| 133 | + | ||
| 106 | 134 | favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | |
| 107 | 135 | ID: entity.FavoriteID(req.ID), | |
| 108 | 136 | Title: req.Title, | |
| @@ -138,6 +166,10 @@ func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler { | |||
| 138 | 166 | Int64("favorite_id", req.ID). | |
| 139 | 167 | Msg("handling favorite_delete") | |
| 140 | 168 | ||
| 169 | + | if req.ID <= 0 { | |
| 170 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | |
| 171 | + | } | |
| 172 | + | ||
| 141 | 173 | if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil { | |
| 142 | 174 | return NewErrorResponse(req.RequestID, err), nil | |
| 143 | 175 | } | |
| @@ -214,24 +246,6 @@ type setFolderRequest struct { | |||
| 214 | 246 | } | |
| 215 | 247 | ||
| 216 | 248 | // HandleSetFolder handles favorite_set_folder messages. | |
| 217 | - | func folderIDFromInt64(id *int64) *entity.FolderID { | |
| 218 | - | if id == nil { | |
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | folderID := entity.FolderID(*id) | |
| 222 | - | return &folderID | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | func tagIDsFromInt64s(ids []int64) []entity.TagID { | |
| 226 | - | out := make([]entity.TagID, 0, len(ids)) | |
| 227 | - | for _, id := range ids { | |
| 228 | - | if id > 0 { | |
| 229 | - | out = append(out, entity.TagID(id)) | |
| 230 | - | } | |
| 231 | - | } | |
| 232 | - | return out | |
| 233 | - | } | |
| 234 | - | ||
| 235 | 249 | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |
| 236 | 250 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 237 | 251 | log := logging.FromContext(ctx) | |
| @@ -246,13 +260,7 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |||
| 246 | 260 | Int64("favorite_id", req.FavoriteID). | |
| 247 | 261 | Msg("handling favorite_set_folder") | |
| 248 | 262 | ||
| 249 | - | var folderID *entity.FolderID | |
| 250 | - | if req.FolderID != nil { | |
| 251 | - | id := entity.FolderID(*req.FolderID) | |
| 252 | - | folderID = &id | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil { | |
| 263 | + | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderIDFromInt64(req.FolderID)); err != nil { | |
| 256 | 264 | return NewErrorResponse(req.RequestID, err), nil | |
| 257 | 265 | } | |
| 258 | 266 | ||
M internal/infrastructure/handlers/homepage/folders.go +6 -9
| @@ -44,6 +44,7 @@ type createFolderRequest struct { | |||
| 44 | 44 | RequestID string `json:"requestId"` | |
| 45 | 45 | Name string `json:"name"` | |
| 46 | 46 | Icon *string `json:"icon"` | |
| 47 | + | ParentID *int64 `json:"parent_id"` | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | // HandleCreate handles folder_create messages. | |
| @@ -61,19 +62,15 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler { | |||
| 61 | 62 | Str("name", req.Name). | |
| 62 | 63 | Msg("handling folder_create") | |
| 63 | 64 | ||
| 64 | - | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, nil) | |
| 65 | + | icon := "" | |
| 66 | + | if req.Icon != nil { | |
| 67 | + | icon = *req.Icon | |
| 68 | + | } | |
| 69 | + | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, icon, folderIDFromInt64(req.ParentID)) | |
| 65 | 70 | if err != nil { | |
| 66 | 71 | return NewErrorResponse(req.RequestID, err), nil | |
| 67 | 72 | } | |
| 68 | 73 | ||
| 69 | - | // Set icon if provided and persist it | |
| 70 | - | if req.Icon != nil && *req.Icon != "" { | |
| 71 | - | folder.Icon = *req.Icon | |
| 72 | - | if err := h.favoritesUC.UpdateFolder(ctx, folder.ID, folder.Name, folder.Icon); err != nil { | |
| 73 | - | return NewErrorResponse(req.RequestID, err), nil | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | 74 | return NewSuccessResponse(req.RequestID, folder), nil | |
| 78 | 75 | }) | |
| 79 | 76 | } | |
M internal/infrastructure/handlers/homepage/history.go +20 -0
| @@ -3,6 +3,8 @@ package homepage | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -68,6 +70,11 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||
| 68 | 70 | return NewErrorResponse("", err), nil | |
| 69 | 71 | } | |
| 70 | 72 | ||
| 73 | + | req.Domain = strings.TrimSpace(req.Domain) | |
| 74 | + | if req.Domain == "" { | |
| 75 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil | |
| 76 | + | } | |
| 77 | + | ||
| 71 | 78 | log.Debug(). | |
| 72 | 79 | Str("request_id", req.RequestID). | |
| 73 | 80 | Str("domain", req.Domain). | |
| @@ -169,11 +176,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler { | |||
| 169 | 176 | return NewErrorResponse("", err), nil | |
| 170 | 177 | } | |
| 171 | 178 | ||
| 179 | + | req.Range = strings.TrimSpace(req.Range) | |
| 172 | 180 | log.Debug(). | |
| 173 | 181 | Str("request_id", req.RequestID). | |
| 174 | 182 | Str("range", req.Range). | |
| 175 | 183 | Msg("handling history_delete_range") | |
| 176 | 184 | ||
| 185 | + | if !isAllowedHistoryDeleteRange(req.Range) { | |
| 186 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid range: %s", req.Range)), nil | |
| 187 | + | } | |
| 177 | 188 | if err := h.historyUC.ClearRange(ctx, req.Range); err != nil { | |
| 178 | 189 | return NewErrorResponse(req.RequestID, err), nil | |
| 179 | 190 | } | |
| @@ -182,6 +193,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler { | |||
| 182 | 193 | }) | |
| 183 | 194 | } | |
| 184 | 195 | ||
| 196 | + | func isAllowedHistoryDeleteRange(rangeID string) bool { | |
| 197 | + | switch rangeID { | |
| 198 | + | case "hour", "day", "week", "month", "all": | |
| 199 | + | return true | |
| 200 | + | default: | |
| 201 | + | return false | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 185 | 205 | // HandleClearAll handles history_clear_all messages. | |
| 186 | 206 | func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | |
| 187 | 207 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
A internal/infrastructure/netguard/transcode.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package netguard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/netip" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | var blockedTranscodeIPPfxs = []netip.Prefix{ | |
| 9 | + | netip.MustParsePrefix("0.0.0.0/8"), | |
| 10 | + | netip.MustParsePrefix("10.0.0.0/8"), | |
| 11 | + | netip.MustParsePrefix("100.64.0.0/10"), | |
| 12 | + | netip.MustParsePrefix("127.0.0.0/8"), | |
| 13 | + | netip.MustParsePrefix("169.254.0.0/16"), | |
| 14 | + | netip.MustParsePrefix("172.16.0.0/12"), | |
| 15 | + | netip.MustParsePrefix("192.0.0.0/24"), | |
| 16 | + | netip.MustParsePrefix("192.0.2.0/24"), | |
| 17 | + | netip.MustParsePrefix("192.168.0.0/16"), | |
| 18 | + | netip.MustParsePrefix("198.18.0.0/15"), | |
| 19 | + | netip.MustParsePrefix("198.51.100.0/24"), | |
| 20 | + | netip.MustParsePrefix("203.0.113.0/24"), | |
| 21 | + | netip.MustParsePrefix("224.0.0.0/4"), | |
| 22 | + | netip.MustParsePrefix("240.0.0.0/4"), | |
| 23 | + | netip.MustParsePrefix("::/128"), | |
| 24 | + | netip.MustParsePrefix("::1/128"), | |
| 25 | + | netip.MustParsePrefix("64:ff9b::/96"), | |
| 26 | + | netip.MustParsePrefix("64:ff9b:1::/48"), | |
| 27 | + | netip.MustParsePrefix("100::/64"), | |
| 28 | + | netip.MustParsePrefix("2001::/32"), | |
| 29 | + | netip.MustParsePrefix("2001:2::/48"), | |
| 30 | + | netip.MustParsePrefix("2001:db8::/32"), | |
| 31 | + | netip.MustParsePrefix("2002::/16"), | |
| 32 | + | netip.MustParsePrefix("fc00::/7"), | |
| 33 | + | netip.MustParsePrefix("fe80::/10"), | |
| 34 | + | netip.MustParsePrefix("ff00::/8"), | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // IsBlockedTranscodeIP reports whether ip belongs to a local, private, | |
| 38 | + | // documentation, transition, or otherwise special-purpose range that a media | |
| 39 | + | // transcoder must not fetch on behalf of web content. | |
| 40 | + | func IsBlockedTranscodeIP(ip net.IP) bool { | |
| 41 | + | addr, ok := netip.AddrFromSlice(ip) | |
| 42 | + | if !ok { | |
| 43 | + | return true | |
| 44 | + | } | |
| 45 | + | addr = addr.Unmap() | |
| 46 | + | for _, prefix := range blockedTranscodeIPPfxs { | |
| 47 | + | if prefix.Contains(addr) { | |
| 48 | + | return true | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | return false | |
| 52 | + | } | |
A internal/infrastructure/netguard/transcode_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package netguard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) { | |
| 9 | + | tests := []string{ | |
| 10 | + | "127.0.0.1", | |
| 11 | + | "10.0.0.1", | |
| 12 | + | "172.16.0.1", | |
| 13 | + | "192.168.1.1", | |
| 14 | + | "100.64.0.1", | |
| 15 | + | "198.18.0.1", | |
| 16 | + | "192.0.2.1", | |
| 17 | + | "169.254.169.254", | |
| 18 | + | "0.0.0.0", | |
| 19 | + | "64:ff9b::a00:1", | |
| 20 | + | "2001::1", | |
| 21 | + | "2001:db8::1", | |
| 22 | + | "2002:0a00:0001::1", | |
| 23 | + | "fc00::1", | |
| 24 | + | "fe80::1", | |
| 25 | + | "::ffff:127.0.0.1", | |
| 26 | + | } | |
| 27 | + | for _, rawIP := range tests { | |
| 28 | + | t.Run(rawIP, func(t *testing.T) { | |
| 29 | + | if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |
| 30 | + | t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP) | |
| 31 | + | } | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) { | |
| 37 | + | if !IsBlockedTranscodeIP(net.ParseIP("")) { | |
| 38 | + | t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) { | |
| 43 | + | for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} { | |
| 44 | + | t.Run(rawIP, func(t *testing.T) { | |
| 45 | + | if IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |
| 46 | + | t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
M internal/infrastructure/persistence/sqlite/favorite_repo.go +6 -8
| @@ -61,6 +61,11 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, | |||
| 61 | 61 | } | |
| 62 | 62 | }() | |
| 63 | 63 | ||
| 64 | + | shortcutKey := sql.NullInt64{} | |
| 65 | + | if fav.ShortcutKey != nil { | |
| 66 | + | shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true} | |
| 67 | + | } | |
| 68 | + | ||
| 64 | 69 | queries := r.queries.WithTx(tx) | |
| 65 | 70 | if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | |
| 66 | 71 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |
| @@ -73,7 +78,7 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, | |||
| 73 | 78 | return err | |
| 74 | 79 | } | |
| 75 | 80 | if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | |
| 76 | - | ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil}, | |
| 81 | + | ShortcutKey: shortcutKey, | |
| 77 | 82 | ID: int64(fav.ID), | |
| 78 | 83 | }); err != nil { | |
| 79 | 84 | return err | |
| @@ -81,13 +86,6 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, | |||
| 81 | 86 | return tx.Commit() | |
| 82 | 87 | } | |
| 83 | 88 | ||
| 84 | - | func int64Value(value *int) int64 { | |
| 85 | - | if value == nil { | |
| 86 | - | return 0 | |
| 87 | - | } | |
| 88 | - | return int64(*value) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | 89 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | |
| 92 | 90 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | |
| 93 | 91 | if err != nil { | |
M internal/infrastructure/persistence/sqlite/history_repo.go +16 -21
| @@ -61,10 +61,12 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro | |||
| 61 | 61 | log := logging.FromContext(ctx) | |
| 62 | 62 | log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry") | |
| 63 | 63 | ||
| 64 | + | domain := domainurl.CanonicalDomain(entry.URL) | |
| 64 | 65 | err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{ | |
| 65 | 66 | Url: entry.URL, | |
| 66 | 67 | Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""}, | |
| 67 | 68 | FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""}, | |
| 69 | + | Domain: sql.NullString{String: domain, Valid: domain != ""}, | |
| 68 | 70 | }) | |
| 69 | 71 | if err != nil { | |
| 70 | 72 | return err | |
| @@ -518,11 +520,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H | |||
| 518 | 520 | return nil, err | |
| 519 | 521 | } | |
| 520 | 522 | ||
| 521 | - | entries := make([]*entity.HistoryEntry, len(rows)) | |
| 522 | - | for i := range rows { | |
| 523 | - | entries[i] = historyFromRow(rows[i]) | |
| 524 | - | } | |
| 525 | - | return entries, nil | |
| 523 | + | return historyEntriesFromRows(rows), nil | |
| 526 | 524 | } | |
| 527 | 525 | ||
| 528 | 526 | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| @@ -536,11 +534,7 @@ func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.H | |||
| 536 | 534 | return nil, err | |
| 537 | 535 | } | |
| 538 | 536 | ||
| 539 | - | entries := make([]*entity.HistoryEntry, len(rows)) | |
| 540 | - | for i := range rows { | |
| 541 | - | entries[i] = historyFromRow(rows[i]) | |
| 542 | - | } | |
| 543 | - | return entries, nil | |
| 537 | + | return historyEntriesFromRows(rows), nil | |
| 544 | 538 | } | |
| 545 | 539 | ||
| 546 | 540 | func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| @@ -549,11 +543,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor | |||
| 549 | 543 | return nil, err | |
| 550 | 544 | } | |
| 551 | 545 | ||
| 552 | - | entries := make([]*entity.HistoryEntry, len(rows)) | |
| 553 | - | for i := range rows { | |
| 554 | - | entries[i] = historyFromRow(rows[i]) | |
| 555 | - | } | |
| 556 | - | return entries, nil | |
| 546 | + | return historyEntriesFromRows(rows), nil | |
| 557 | 547 | } | |
| 558 | 548 | ||
| 559 | 549 | func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| @@ -562,11 +552,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||
| 562 | 552 | return nil, err | |
| 563 | 553 | } | |
| 564 | 554 | ||
| 565 | - | entries := make([]*entity.HistoryEntry, len(rows)) | |
| 566 | - | for i := range rows { | |
| 567 | - | entries[i] = historyFromRow(rows[i]) | |
| 568 | - | } | |
| 569 | - | return entries, nil | |
| 555 | + | return historyEntriesFromRows(rows), nil | |
| 570 | 556 | } | |
| 571 | 557 | ||
| 572 | 558 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | |
| @@ -599,13 +585,22 @@ func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) err | |||
| 599 | 585 | return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true}) | |
| 600 | 586 | } | |
| 601 | 587 | ||
| 588 | + | func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error { | |
| 589 | + | return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true}) | |
| 590 | + | } | |
| 591 | + | ||
| 602 | 592 | func (r *historyRepo) DeleteAll(ctx context.Context) error { | |
| 603 | 593 | return r.queries.DeleteAllHistory(ctx) | |
| 604 | 594 | } | |
| 605 | 595 | ||
| 606 | 596 | func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error { | |
| 597 | + | rawDomain := domain | |
| 607 | 598 | domain = domainurl.CanonicalDomain(domain) | |
| 608 | - | return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: domain != ""}) | |
| 599 | + | if domain == "" { | |
| 600 | + | logging.FromContext(ctx).Debug().Str("domain", rawDomain).Msg("skipping delete by domain: empty canonical domain") | |
| 601 | + | return nil | |
| 602 | + | } | |
| 603 | + | return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true}) | |
| 609 | 604 | } | |
| 610 | 605 | ||
| 611 | 606 | func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |
M internal/infrastructure/persistence/sqlite/history_repo_test.go +38 -0
| @@ -832,3 +832,41 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) | |||
| 832 | 832 | require.NoError(t, err) | |
| 833 | 833 | assert.Empty(t, results) | |
| 834 | 834 | } | |
| 835 | + | ||
| 836 | + | func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) { | |
| 837 | + | ctx := historyTestCtx() | |
| 838 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 839 | + | ||
| 840 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 841 | + | require.NoError(t, err) | |
| 842 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 843 | + | ||
| 844 | + | repo := sqlite.NewHistoryRepository(db) | |
| 845 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"})) | |
| 846 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"})) | |
| 847 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"})) | |
| 848 | + | ||
| 849 | + | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 10, 0) | |
| 850 | + | require.NoError(t, err) | |
| 851 | + | require.Len(t, exampleEntries, 2) | |
| 852 | + | for _, entry := range exampleEntries { | |
| 853 | + | assert.Contains(t, entry.URL, "example.com") | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | stats, err := repo.GetDomainStats(ctx, 10) | |
| 857 | + | require.NoError(t, err) | |
| 858 | + | require.NotEmpty(t, stats) | |
| 859 | + | assert.Equal(t, "example.com", stats[0].Domain) | |
| 860 | + | assert.Equal(t, int64(2), stats[0].PageCount) | |
| 861 | + | ||
| 862 | + | require.NoError(t, repo.DeleteByDomain(ctx, "example.com")) | |
| 863 | + | ||
| 864 | + | exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0) | |
| 865 | + | require.NoError(t, err) | |
| 866 | + | assert.Empty(t, exampleEntries) | |
| 867 | + | ||
| 868 | + | remaining, err := repo.GetAllRecentHistory(ctx) | |
| 869 | + | require.NoError(t, err) | |
| 870 | + | require.Len(t, remaining, 1) | |
| 871 | + | assert.Equal(t, "https://other.test", remaining[0].URL) | |
| 872 | + | } | |
M internal/infrastructure/persistence/sqlite/lazy_repos.go +7 -0
| @@ -230,6 +230,13 @@ func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time | |||
| 230 | 230 | return r.repo.DeleteOlderThan(ctx, before) | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | + | func (r *LazyHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error { | |
| 234 | + | if err := r.init(ctx); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | return r.repo.DeleteSince(ctx, since) | |
| 238 | + | } | |
| 239 | + | ||
| 233 | 240 | func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error { | |
| 234 | 241 | if err := r.init(ctx); err != nil { | |
| 235 | 242 | return err | |
A internal/infrastructure/persistence/sqlite/migrations/008_history_domain.sql +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | -- +goose Up | |
| 2 | + | -- Store normalized history domains for indexed systemview filtering. | |
| 3 | + | ||
| 4 | + | ALTER TABLE history ADD COLUMN domain TEXT; | |
| 5 | + | ||
| 6 | + | UPDATE history | |
| 7 | + | SET domain = LOWER( | |
| 8 | + | CASE | |
| 9 | + | WHEN INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') > 0 THEN | |
| 10 | + | SUBSTR( | |
| 11 | + | REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), | |
| 12 | + | 1, | |
| 13 | + | INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') - 1 | |
| 14 | + | ) | |
| 15 | + | ELSE REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/') | |
| 16 | + | END | |
| 17 | + | ) | |
| 18 | + | WHERE INSTR(url, '://') > 0; | |
| 19 | + | ||
| 20 | + | -- Mirror domainurl.CanonicalDomain for legacy rows as closely as SQLite can: | |
| 21 | + | -- strip userinfo first, then strip the leading www. alias. | |
| 22 | + | UPDATE history | |
| 23 | + | SET domain = SUBSTR(domain, INSTR(domain, '@') + 1) | |
| 24 | + | WHERE INSTR(domain, '@') > 0; | |
| 25 | + | ||
| 26 | + | UPDATE history | |
| 27 | + | SET domain = SUBSTR(domain, 5) | |
| 28 | + | WHERE domain LIKE 'www.%'; | |
| 29 | + | ||
| 30 | + | CREATE INDEX IF NOT EXISTS idx_history_domain_last_visited ON history(domain, last_visited DESC); | |
| 31 | + | ||
| 32 | + | -- +goose Down | |
| 33 | + | DROP INDEX IF EXISTS idx_history_domain_last_visited; | |
| 34 | + | ALTER TABLE history DROP COLUMN domain; | |
M internal/infrastructure/persistence/sqlite/queries/history.sql +11 -22
| @@ -3,14 +3,7 @@ SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?; | |||
| 3 | 3 | ||
| 4 | 4 | -- name: GetRecentHistoryByDomain :many | |
| 5 | 5 | SELECT * FROM history | |
| 6 | - | WHERE url LIKE '%://' || @domain || '/%' | |
| 7 | - | OR url LIKE '%://' || @domain || '?%' | |
| 8 | - | OR url LIKE '%://' || @domain || '#%' | |
| 9 | - | OR url LIKE '%://' || @domain | |
| 10 | - | OR url LIKE '%://www.' || @domain || '/%' | |
| 11 | - | OR url LIKE '%://www.' || @domain || '?%' | |
| 12 | - | OR url LIKE '%://www.' || @domain || '#%' | |
| 13 | - | OR url LIKE '%://www.' || @domain | |
| 6 | + | WHERE domain = @domain | |
| 14 | 7 | ORDER BY last_visited DESC | |
| 15 | 8 | LIMIT @limit OFFSET @offset; | |
| 16 | 9 | ||
| @@ -18,14 +11,15 @@ LIMIT @limit OFFSET @offset; | |||
| 18 | 11 | SELECT * FROM history WHERE url = ? LIMIT 1; | |
| 19 | 12 | ||
| 20 | 13 | -- name: UpsertHistory :exec | |
| 21 | - | INSERT INTO history (url, title, favicon_url) | |
| 22 | - | VALUES (?, ?, ?) | |
| 14 | + | INSERT INTO history (url, title, favicon_url, domain) | |
| 15 | + | VALUES (?, ?, ?, ?) | |
| 23 | 16 | ON CONFLICT(url) | |
| 24 | 17 | DO UPDATE SET | |
| 25 | 18 | visit_count = visit_count + 1, | |
| 26 | 19 | last_visited = CURRENT_TIMESTAMP, | |
| 27 | 20 | title = COALESCE(EXCLUDED.title, history.title), | |
| 28 | - | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url); | |
| 21 | + | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url), | |
| 22 | + | domain = EXCLUDED.domain; | |
| 29 | 23 | ||
| 30 | 24 | -- name: IncrementVisitCount :exec | |
| 31 | 25 | UPDATE history | |
| @@ -49,6 +43,9 @@ DELETE FROM history WHERE id = ?; | |||
| 49 | 43 | -- name: DeleteHistoryOlderThan :exec | |
| 50 | 44 | DELETE FROM history WHERE last_visited < ?; | |
| 51 | 45 | ||
| 46 | + | -- name: DeleteHistorySince :exec | |
| 47 | + | DELETE FROM history WHERE last_visited >= ?; | |
| 48 | + | ||
| 52 | 49 | -- name: DeleteAllHistory :exec | |
| 53 | 50 | DELETE FROM history; | |
| 54 | 51 | ||
| @@ -56,7 +53,7 @@ DELETE FROM history; | |||
| 56 | 53 | SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history; | |
| 57 | 54 | ||
| 58 | 55 | -- name: GetDomainStats :many | |
| 59 | - | SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?; | |
| 56 | + | SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?; | |
| 60 | 57 | ||
| 61 | 58 | -- name: GetHourlyDistribution :many | |
| 62 | 59 | SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour; | |
| @@ -65,15 +62,7 @@ SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_ | |||
| 65 | 62 | SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC; | |
| 66 | 63 | ||
| 67 | 64 | -- name: DeleteHistoryByDomain :exec | |
| 68 | - | DELETE FROM history | |
| 69 | - | WHERE url LIKE '%://' || @domain || '/%' | |
| 70 | - | OR url LIKE '%://' || @domain || '?%' | |
| 71 | - | OR url LIKE '%://' || @domain || '#%' | |
| 72 | - | OR url LIKE '%://' || @domain | |
| 73 | - | OR url LIKE '%://www.' || @domain || '/%' | |
| 74 | - | OR url LIKE '%://www.' || @domain || '?%' | |
| 75 | - | OR url LIKE '%://www.' || @domain || '#%' | |
| 76 | - | OR url LIKE '%://www.' || @domain; | |
| 65 | + | DELETE FROM history WHERE domain = @domain; | |
| 77 | 66 | ||
| 78 | 67 | -- name: SearchHistoryFTSUrl :many | |
| 79 | 68 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| @@ -99,7 +88,7 @@ ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | |||
| 99 | 88 | LIMIT @limit; | |
| 100 | 89 | ||
| 101 | 90 | -- name: SearchHistoryFTSTitle :many | |
| 102 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 91 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain | |
| 103 | 92 | FROM history_fts fts | |
| 104 | 93 | JOIN history h ON fts.rowid = h.id | |
| 105 | 94 | WHERE fts.title MATCH @query | |
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +54 -34
| @@ -35,15 +35,7 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error { | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec | |
| 38 | - | DELETE FROM history | |
| 39 | - | WHERE url LIKE '%://' || ?1 || '/%' | |
| 40 | - | OR url LIKE '%://' || ?1 || '?%' | |
| 41 | - | OR url LIKE '%://' || ?1 || '#%' | |
| 42 | - | OR url LIKE '%://' || ?1 | |
| 43 | - | OR url LIKE '%://www.' || ?1 || '/%' | |
| 44 | - | OR url LIKE '%://www.' || ?1 || '?%' | |
| 45 | - | OR url LIKE '%://www.' || ?1 || '#%' | |
| 46 | - | OR url LIKE '%://www.' || ?1 | |
| 38 | + | DELETE FROM history WHERE domain = ?1 | |
| 47 | 39 | ` | |
| 48 | 40 | ||
| 49 | 41 | func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error { | |
| @@ -69,8 +61,17 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu | |||
| 69 | 61 | return err | |
| 70 | 62 | } | |
| 71 | 63 | ||
| 64 | + | const DeleteHistorySince = `-- name: DeleteHistorySince :exec | |
| 65 | + | DELETE FROM history WHERE last_visited >= ? | |
| 66 | + | ` | |
| 67 | + | ||
| 68 | + | func (q *Queries) DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error { | |
| 69 | + | _, err := q.db.ExecContext(ctx, DeleteHistorySince, lastVisited) | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | ||
| 72 | 73 | const GetAllMostVisited = `-- name: GetAllMostVisited :many | |
| 73 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 74 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 74 | 75 | ORDER BY visit_count DESC, last_visited DESC | |
| 75 | 76 | ` | |
| 76 | 77 | ||
| @@ -91,6 +92,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||
| 91 | 92 | &i.VisitCount, | |
| 92 | 93 | &i.LastVisited, | |
| 93 | 94 | &i.CreatedAt, | |
| 95 | + | &i.Domain, | |
| 94 | 96 | ); err != nil { | |
| 95 | 97 | return nil, err | |
| 96 | 98 | } | |
| @@ -106,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||
| 106 | 108 | } | |
| 107 | 109 | ||
| 108 | 110 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | |
| 109 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 111 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 110 | 112 | ORDER BY last_visited DESC | |
| 111 | 113 | ` | |
| 112 | 114 | ||
| @@ -127,6 +129,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||
| 127 | 129 | &i.VisitCount, | |
| 128 | 130 | &i.LastVisited, | |
| 129 | 131 | &i.CreatedAt, | |
| 132 | + | &i.Domain, | |
| 130 | 133 | ); err != nil { | |
| 131 | 134 | return nil, err | |
| 132 | 135 | } | |
| @@ -175,7 +178,7 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G | |||
| 175 | 178 | } | |
| 176 | 179 | ||
| 177 | 180 | const GetDomainStats = `-- name: GetDomainStats :many | |
| 178 | - | SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ? | |
| 181 | + | SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ? | |
| 179 | 182 | ` | |
| 180 | 183 | ||
| 181 | 184 | type GetDomainStatsRow struct { | |
| @@ -214,7 +217,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS | |||
| 214 | 217 | } | |
| 215 | 218 | ||
| 216 | 219 | const GetHistoryByURL = `-- name: GetHistoryByURL :one | |
| 217 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1 | |
| 220 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history WHERE url = ? LIMIT 1 | |
| 218 | 221 | ` | |
| 219 | 222 | ||
| 220 | 223 | func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) { | |
| @@ -228,6 +231,7 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err | |||
| 228 | 231 | &i.VisitCount, | |
| 229 | 232 | &i.LastVisited, | |
| 230 | 233 | &i.CreatedAt, | |
| 234 | + | &i.Domain, | |
| 231 | 235 | ) | |
| 232 | 236 | return i, err | |
| 233 | 237 | } | |
| @@ -282,7 +286,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib | |||
| 282 | 286 | } | |
| 283 | 287 | ||
| 284 | 288 | const GetMostVisited = `-- name: GetMostVisited :many | |
| 285 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 289 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 286 | 290 | WHERE last_visited >= datetime('now', ?) | |
| 287 | 291 | ORDER BY visit_count DESC, last_visited DESC | |
| 288 | 292 | ` | |
| @@ -304,6 +308,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||
| 304 | 308 | &i.VisitCount, | |
| 305 | 309 | &i.LastVisited, | |
| 306 | 310 | &i.CreatedAt, | |
| 311 | + | &i.Domain, | |
| 307 | 312 | ); err != nil { | |
| 308 | 313 | return nil, err | |
| 309 | 314 | } | |
| @@ -319,7 +324,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||
| 319 | 324 | } | |
| 320 | 325 | ||
| 321 | 326 | const GetRecentHistory = `-- name: GetRecentHistory :many | |
| 322 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | |
| 327 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | |
| 323 | 328 | ` | |
| 324 | 329 | ||
| 325 | 330 | type GetRecentHistoryParams struct { | |
| @@ -344,6 +349,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||
| 344 | 349 | &i.VisitCount, | |
| 345 | 350 | &i.LastVisited, | |
| 346 | 351 | &i.CreatedAt, | |
| 352 | + | &i.Domain, | |
| 347 | 353 | ); err != nil { | |
| 348 | 354 | return nil, err | |
| 349 | 355 | } | |
| @@ -359,15 +365,8 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||
| 359 | 365 | } | |
| 360 | 366 | ||
| 361 | 367 | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | |
| 362 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 363 | - | WHERE url LIKE '%://' || ?1 || '/%' | |
| 364 | - | OR url LIKE '%://' || ?1 || '?%' | |
| 365 | - | OR url LIKE '%://' || ?1 || '#%' | |
| 366 | - | OR url LIKE '%://' || ?1 | |
| 367 | - | OR url LIKE '%://www.' || ?1 || '/%' | |
| 368 | - | OR url LIKE '%://www.' || ?1 || '?%' | |
| 369 | - | OR url LIKE '%://www.' || ?1 || '#%' | |
| 370 | - | OR url LIKE '%://www.' || ?1 | |
| 368 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 369 | + | WHERE domain = ?1 | |
| 371 | 370 | ORDER BY last_visited DESC | |
| 372 | 371 | LIMIT ?3 OFFSET ?2 | |
| 373 | 372 | ` | |
| @@ -395,6 +394,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis | |||
| 395 | 394 | &i.VisitCount, | |
| 396 | 395 | &i.LastVisited, | |
| 397 | 396 | &i.CreatedAt, | |
| 397 | + | &i.Domain, | |
| 398 | 398 | ); err != nil { | |
| 399 | 399 | return nil, err | |
| 400 | 400 | } | |
| @@ -410,7 +410,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis | |||
| 410 | 410 | } | |
| 411 | 411 | ||
| 412 | 412 | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | |
| 413 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 413 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 414 | 414 | WHERE last_visited >= datetime('now', ?) | |
| 415 | 415 | ORDER BY last_visited DESC | |
| 416 | 416 | ` | |
| @@ -432,6 +432,7 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||
| 432 | 432 | &i.VisitCount, | |
| 433 | 433 | &i.LastVisited, | |
| 434 | 434 | &i.CreatedAt, | |
| 435 | + | &i.Domain, | |
| 435 | 436 | ); err != nil { | |
| 436 | 437 | return nil, err | |
| 437 | 438 | } | |
| @@ -474,7 +475,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV | |||
| 474 | 475 | } | |
| 475 | 476 | ||
| 476 | 477 | const SearchHistory = `-- name: SearchHistory :many | |
| 477 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 478 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |
| 478 | 479 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | |
| 479 | 480 | ORDER BY visit_count DESC, last_visited DESC | |
| 480 | 481 | LIMIT ? | |
| @@ -503,6 +504,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||
| 503 | 504 | &i.VisitCount, | |
| 504 | 505 | &i.LastVisited, | |
| 505 | 506 | &i.CreatedAt, | |
| 507 | + | &i.Domain, | |
| 506 | 508 | ); err != nil { | |
| 507 | 509 | return nil, err | |
| 508 | 510 | } | |
| @@ -518,7 +520,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||
| 518 | 520 | } | |
| 519 | 521 | ||
| 520 | 522 | const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many | |
| 521 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | |
| 523 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain | |
| 522 | 524 | FROM history_fts fts | |
| 523 | 525 | JOIN history h ON fts.rowid = h.id | |
| 524 | 526 | WHERE fts.title MATCH ?1 | |
| @@ -548,6 +550,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT | |||
| 548 | 550 | &i.VisitCount, | |
| 549 | 551 | &i.LastVisited, | |
| 550 | 552 | &i.CreatedAt, | |
| 553 | + | &i.Domain, | |
| 551 | 554 | ); err != nil { | |
| 552 | 555 | return nil, err | |
| 553 | 556 | } | |
| @@ -576,15 +579,25 @@ type SearchHistoryFTSUrlParams struct { | |||
| 576 | 579 | Limit int64 `json:"limit"` | |
| 577 | 580 | } | |
| 578 | 581 | ||
| 579 | - | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) { | |
| 582 | + | type SearchHistoryFTSUrlRow struct { | |
| 583 | + | ID int64 `json:"id"` | |
| 584 | + | Url string `json:"url"` | |
| 585 | + | Title sql.NullString `json:"title"` | |
| 586 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |
| 587 | + | VisitCount sql.NullInt64 `json:"visit_count"` | |
| 588 | + | LastVisited sql.NullTime `json:"last_visited"` | |
| 589 | + | CreatedAt sql.NullTime `json:"created_at"` | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) { | |
| 580 | 593 | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) | |
| 581 | 594 | if err != nil { | |
| 582 | 595 | return nil, err | |
| 583 | 596 | } | |
| 584 | 597 | defer rows.Close() | |
| 585 | - | items := []History{} | |
| 598 | + | items := []SearchHistoryFTSUrlRow{} | |
| 586 | 599 | for rows.Next() { | |
| 587 | - | var i History | |
| 600 | + | var i SearchHistoryFTSUrlRow | |
| 588 | 601 | if err := rows.Scan( | |
| 589 | 602 | &i.ID, | |
| 590 | 603 | &i.Url, | |
| @@ -673,23 +686,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se | |||
| 673 | 686 | } | |
| 674 | 687 | ||
| 675 | 688 | const UpsertHistory = `-- name: UpsertHistory :exec | |
| 676 | - | INSERT INTO history (url, title, favicon_url) | |
| 677 | - | VALUES (?, ?, ?) | |
| 689 | + | INSERT INTO history (url, title, favicon_url, domain) | |
| 690 | + | VALUES (?, ?, ?, ?) | |
| 678 | 691 | ON CONFLICT(url) | |
| 679 | 692 | DO UPDATE SET | |
| 680 | 693 | visit_count = visit_count + 1, | |
| 681 | 694 | last_visited = CURRENT_TIMESTAMP, | |
| 682 | 695 | title = COALESCE(EXCLUDED.title, history.title), | |
| 683 | - | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url) | |
| 696 | + | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url), | |
| 697 | + | domain = EXCLUDED.domain | |
| 684 | 698 | ` | |
| 685 | 699 | ||
| 686 | 700 | type UpsertHistoryParams struct { | |
| 687 | 701 | Url string `json:"url"` | |
| 688 | 702 | Title sql.NullString `json:"title"` | |
| 689 | 703 | FaviconUrl sql.NullString `json:"favicon_url"` | |
| 704 | + | Domain sql.NullString `json:"domain"` | |
| 690 | 705 | } | |
| 691 | 706 | ||
| 692 | 707 | func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error { | |
| 693 | - | _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl) | |
| 708 | + | _, err := q.db.ExecContext(ctx, UpsertHistory, | |
| 709 | + | arg.Url, | |
| 710 | + | arg.Title, | |
| 711 | + | arg.FaviconUrl, | |
| 712 | + | arg.Domain, | |
| 713 | + | ) | |
| 694 | 714 | return err | |
| 695 | 715 | } | |
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
| @@ -56,6 +56,7 @@ type History struct { | |||
| 56 | 56 | VisitCount sql.NullInt64 `json:"visit_count"` | |
| 57 | 57 | LastVisited sql.NullTime `json:"last_visited"` | |
| 58 | 58 | CreatedAt sql.NullTime `json:"created_at"` | |
| 59 | + | Domain sql.NullString `json:"domain"` | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | 62 | type HistoryFt struct { | |
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
| @@ -24,6 +24,7 @@ type Querier interface { | |||
| 24 | 24 | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | |
| 25 | 25 | DeleteHistoryByID(ctx context.Context, id int64) error | |
| 26 | 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 27 | + | DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error | |
| 27 | 28 | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 28 | 29 | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | |
| 29 | 30 | DeletePermission(ctx context.Context, arg DeletePermissionParams) error | |
| @@ -78,7 +79,7 @@ type Querier interface { | |||
| 78 | 79 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | |
| 79 | 80 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | |
| 80 | 81 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | |
| 81 | - | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | |
| 82 | + | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) | |
| 82 | 83 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | |
| 83 | 84 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | |
| 84 | 85 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | |
M internal/infrastructure/systemviewsbridge/browser_transport.go +28 -10
| @@ -51,6 +51,9 @@ func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 51 | 51 | }) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | + | // normalizeBridgeShimResponse accepts the bridge shapes emitted by native and | |
| 55 | + | // fallback shims: a direct bridgeResponse envelope, a wrapper with data/error, | |
| 56 | + | // or a wrapper whose data field itself contains a bridgeResponse envelope. | |
| 54 | 57 | func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) { | |
| 55 | 58 | trimmed := bytes.TrimSpace(body) | |
| 56 | 59 | if len(trimmed) == 0 { | |
| @@ -67,21 +70,19 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) | |||
| 67 | 70 | _, hasCallback := topLevel["_callback"] | |
| 68 | 71 | _, hasData := topLevel["data"] | |
| 69 | 72 | ||
| 73 | + | // Native callbacks can already return the final bridgeResponse shape. | |
| 70 | 74 | if (hasSuccess || hasRequestID) && !hasCallback { | |
| 71 | 75 | var resp bridgeResponse | |
| 72 | 76 | if err := json.Unmarshal(trimmed, &resp); err != nil { | |
| 73 | 77 | return nil, fmt.Errorf("unmarshal direct bridge response: %w", err) | |
| 74 | 78 | } | |
| 75 | 79 | if resp.RequestID == "" { | |
| 80 | + | // Some direct shims omit requestId; preserve correlation for callers. | |
| 76 | 81 | resp.RequestID = requestID | |
| 77 | 82 | } | |
| 78 | 83 | return json.Marshal(resp) | |
| 79 | 84 | } | |
| 80 | 85 | ||
| 81 | - | if !hasData { | |
| 82 | - | return nil, fmt.Errorf("wrapped bridge response missing data") | |
| 83 | - | } | |
| 84 | - | ||
| 85 | 86 | var wrapped struct { | |
| 86 | 87 | Data json.RawMessage `json:"data,omitempty"` | |
| 87 | 88 | Error string `json:"error,omitempty"` | |
| @@ -90,20 +91,37 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) | |||
| 90 | 91 | return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err) | |
| 91 | 92 | } | |
| 92 | 93 | if wrapped.Error != "" { | |
| 94 | + | // Fetch-style wrappers report transport errors outside the data payload. | |
| 93 | 95 | return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error}) | |
| 94 | 96 | } | |
| 97 | + | if !hasData { | |
| 98 | + | // Wrapped shim responses must carry either data or a top-level error. | |
| 99 | + | return nil, fmt.Errorf("wrapped bridge response missing data") | |
| 100 | + | } | |
| 95 | 101 | if len(wrapped.Data) == 0 { | |
| 96 | 102 | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |
| 97 | 103 | } | |
| 98 | 104 | ||
| 99 | - | var nested bridgeResponse | |
| 100 | - | if err := json.Unmarshal(wrapped.Data, &nested); err == nil { | |
| 101 | - | hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != "" | |
| 105 | + | var nestedFields map[string]json.RawMessage | |
| 106 | + | if err := json.Unmarshal(wrapped.Data, &nestedFields); err == nil { | |
| 107 | + | nestedSuccess, nestedHasSuccess := nestedFields["success"] | |
| 108 | + | _, nestedHasRequestID := nestedFields["requestId"] | |
| 109 | + | _, nestedHasError := nestedFields["error"] | |
| 110 | + | successIsBool := false | |
| 111 | + | if nestedHasSuccess { | |
| 112 | + | var successValue bool | |
| 113 | + | successIsBool = json.Unmarshal(nestedSuccess, &successValue) == nil | |
| 114 | + | } | |
| 115 | + | hasNestedEnvelope := successIsBool || (nestedHasRequestID && nestedHasError) | |
| 102 | 116 | if hasNestedEnvelope { | |
| 103 | - | if nested.RequestID == "" { | |
| 104 | - | nested.RequestID = requestID | |
| 117 | + | var nested bridgeResponse | |
| 118 | + | if err := json.Unmarshal(wrapped.Data, &nested); err == nil { | |
| 119 | + | if nested.RequestID == "" { | |
| 120 | + | // Nested envelopes from direct API wrappers also need caller correlation. | |
| 121 | + | nested.RequestID = requestID | |
| 122 | + | } | |
| 123 | + | return json.Marshal(nested) | |
| 105 | 124 | } | |
| 106 | - | return json.Marshal(nested) | |
| 107 | 125 | } | |
| 108 | 126 | } | |
| 109 | 127 | ||
M internal/infrastructure/systemviewsbridge/browser_transport_js.go +213 -88
| @@ -51,7 +51,8 @@ func (*browserTransport) Available() bool { | |||
| 51 | 51 | if !window.Truthy() { | |
| 52 | 52 | return false | |
| 53 | 53 | } | |
| 54 | - | return window.Get("fetch").Truthy() || window.Get("dumber").Truthy() | |
| 54 | + | bridge := window.Get("dumber") | |
| 55 | + | return bridge.Truthy() && bridge.Get("postMessage").Truthy() | |
| 55 | 56 | } | |
| 56 | 57 | ||
| 57 | 58 | func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |
| @@ -83,6 +84,31 @@ func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) | |||
| 83 | 84 | ||
| 84 | 85 | type webkitTransport struct{} | |
| 85 | 86 | ||
| 87 | + | type webkitCallbackResult struct { | |
| 88 | + | body []byte | |
| 89 | + | err error | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | type webkitCallbackWaiter struct { | |
| 93 | + | requestID string | |
| 94 | + | successName string | |
| 95 | + | failureName string | |
| 96 | + | resultCh chan webkitCallbackResult | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // webkitCallbacks uses one page-lifetime dispatcher per native callback name. | |
| 100 | + | // Requests sharing the same callback names are intentionally serialized because | |
| 101 | + | // older native shims do not always echo requestId; late callbacks without a | |
| 102 | + | // matching waiter are dropped instead of targeting released js.Func handles. | |
| 103 | + | var webkitCallbacks = struct { | |
| 104 | + | sync.Mutex | |
| 105 | + | installed map[string]js.Func | |
| 106 | + | waiters map[string]*webkitCallbackWaiter | |
| 107 | + | }{ | |
| 108 | + | installed: make(map[string]js.Func), | |
| 109 | + | waiters: make(map[string]*webkitCallbackWaiter), | |
| 110 | + | } | |
| 111 | + | ||
| 86 | 112 | func (*webkitTransport) Available() bool { | |
| 87 | 113 | window := js.Global().Get("window") | |
| 88 | 114 | if !window.Truthy() { | |
| @@ -119,73 +145,126 @@ func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |||
| 119 | 145 | return nil, errors.New("webkit message handler not available") | |
| 120 | 146 | } | |
| 121 | 147 | ||
| 122 | - | type result struct { | |
| 123 | - | body []byte | |
| 124 | - | err error | |
| 148 | + | ensureWebKitCallback(window, plan.success, false) | |
| 149 | + | ensureWebKitCallback(window, plan.failure, true) | |
| 150 | + | ||
| 151 | + | resultCh := make(chan webkitCallbackResult, 1) | |
| 152 | + | unregister, err := registerWebKitWaiter(req.requestID, plan, resultCh) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 125 | 155 | } | |
| 126 | - | resultCh := make(chan result, 1) | |
| 127 | - | var callbackMu sync.Mutex | |
| 128 | - | callbacksActive := true | |
| 129 | - | publish := func(res result) { | |
| 130 | - | callbackMu.Lock() | |
| 131 | - | active := callbacksActive | |
| 132 | - | callbackMu.Unlock() | |
| 133 | - | if !active { | |
| 134 | - | return | |
| 135 | - | } | |
| 136 | - | select { | |
| 137 | - | case resultCh <- res: | |
| 138 | - | default: | |
| 139 | - | } | |
| 156 | + | ||
| 157 | + | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |
| 158 | + | ||
| 159 | + | select { | |
| 160 | + | case res := <-resultCh: | |
| 161 | + | return res.body, res.err | |
| 162 | + | case <-ctx.Done(): | |
| 163 | + | unregister() | |
| 164 | + | return nil, ctx.Err() | |
| 140 | 165 | } | |
| 166 | + | } | |
| 141 | 167 | ||
| 142 | - | successFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 143 | - | payload, err := stringifyArgs(args) | |
| 144 | - | if err != nil { | |
| 145 | - | publish(result{err: err}) | |
| 146 | - | return nil | |
| 147 | - | } | |
| 148 | - | normalized, err := normalizeNativeSuccessResponse(req.requestID, payload) | |
| 149 | - | publish(result{body: normalized, err: err}) | |
| 168 | + | func ensureWebKitCallback(window js.Value, name string, failure bool) { | |
| 169 | + | webkitCallbacks.Lock() | |
| 170 | + | if _, ok := webkitCallbacks.installed[name]; ok { | |
| 171 | + | webkitCallbacks.Unlock() | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | callback := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 175 | + | handleWebKitCallback(name, failure, args) | |
| 150 | 176 | return nil | |
| 151 | 177 | }) | |
| 152 | - | errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 153 | - | payload, err := stringifyArgs(args) | |
| 154 | - | if err != nil { | |
| 155 | - | publish(result{err: err}) | |
| 156 | - | return nil | |
| 178 | + | webkitCallbacks.installed[name] = callback | |
| 179 | + | // Installed callbacks are page-lifetime dispatchers. Per-request waiters are | |
| 180 | + | // removed on completion/cancellation, so late native responses are dropped | |
| 181 | + | // without invoking released js.Func handles. | |
| 182 | + | window.Set(name, callback) | |
| 183 | + | webkitCallbacks.Unlock() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan webkitCallbackResult) (func(), error) { | |
| 187 | + | waiter := &webkitCallbackWaiter{ | |
| 188 | + | requestID: requestID, | |
| 189 | + | successName: plan.success, | |
| 190 | + | failureName: plan.failure, | |
| 191 | + | resultCh: resultCh, | |
| 192 | + | } | |
| 193 | + | webkitCallbacks.Lock() | |
| 194 | + | defer webkitCallbacks.Unlock() | |
| 195 | + | if webkitCallbacks.waiters[plan.success] != nil || webkitCallbacks.waiters[plan.failure] != nil { | |
| 196 | + | return nil, fmt.Errorf("webkit callback already pending for %s", plan.success) | |
| 197 | + | } | |
| 198 | + | webkitCallbacks.waiters[plan.success] = waiter | |
| 199 | + | webkitCallbacks.waiters[plan.failure] = waiter | |
| 200 | + | return func() { | |
| 201 | + | webkitCallbacks.Lock() | |
| 202 | + | defer webkitCallbacks.Unlock() | |
| 203 | + | if webkitCallbacks.waiters[plan.success] == waiter { | |
| 204 | + | delete(webkitCallbacks.waiters, plan.success) | |
| 157 | 205 | } | |
| 158 | - | normalized, err := normalizeNativeErrorResponse(req.requestID, payload) | |
| 159 | - | publish(result{body: normalized, err: err}) | |
| 160 | - | return nil | |
| 161 | - | }) | |
| 206 | + | if webkitCallbacks.waiters[plan.failure] == waiter { | |
| 207 | + | delete(webkitCallbacks.waiters, plan.failure) | |
| 208 | + | } | |
| 209 | + | }, nil | |
| 210 | + | } | |
| 162 | 211 | ||
| 163 | - | previousSuccess := window.Get(plan.success) | |
| 164 | - | previousError := window.Get(plan.failure) | |
| 165 | - | window.Set(plan.success, successFn) | |
| 166 | - | window.Set(plan.failure, errorFn) | |
| 167 | - | defer func() { | |
| 168 | - | callbackMu.Lock() | |
| 169 | - | callbacksActive = false | |
| 170 | - | callbackMu.Unlock() | |
| 171 | - | window.Set(plan.success, previousSuccess) | |
| 172 | - | window.Set(plan.failure, previousError) | |
| 173 | - | successFn.Release() | |
| 174 | - | errorFn.Release() | |
| 175 | - | }() | |
| 212 | + | func handleWebKitCallback(name string, failure bool, args []js.Value) { | |
| 213 | + | payload, payloadErr := stringifyArgs(args) | |
| 214 | + | payloadRequestID := requestIDFromBridgePayload(payload) | |
| 176 | 215 | ||
| 177 | - | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |
| 216 | + | webkitCallbacks.Lock() | |
| 217 | + | waiter := webkitCallbacks.waiters[name] | |
| 218 | + | if waiter != nil && payloadRequestID != "" && waiter.requestID != "" && payloadRequestID != waiter.requestID { | |
| 219 | + | webkitCallbacks.Unlock() | |
| 220 | + | warnDroppedBridgeResult("webkit callback request mismatch", fmt.Errorf("got %s, want %s", payloadRequestID, waiter.requestID)) | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if waiter != nil { | |
| 224 | + | delete(webkitCallbacks.waiters, waiter.successName) | |
| 225 | + | delete(webkitCallbacks.waiters, waiter.failureName) | |
| 226 | + | } | |
| 227 | + | webkitCallbacks.Unlock() | |
| 178 | 228 | ||
| 229 | + | if waiter == nil { | |
| 230 | + | warnDroppedBridgeResult("webkit callback without waiter", nil) | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | res := webkitCallbackResult{err: payloadErr} | |
| 235 | + | if payloadErr == nil { | |
| 236 | + | if failure { | |
| 237 | + | res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload) | |
| 238 | + | } else { | |
| 239 | + | res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload) | |
| 240 | + | } | |
| 241 | + | } | |
| 179 | 242 | select { | |
| 180 | - | case res := <-resultCh: | |
| 181 | - | return res.body, res.err | |
| 182 | - | case <-ctx.Done(): | |
| 183 | - | return nil, ctx.Err() | |
| 243 | + | case waiter.resultCh <- res: | |
| 244 | + | default: | |
| 245 | + | warnDroppedBridgeResult("webkit callback", res.err) | |
| 184 | 246 | } | |
| 185 | 247 | } | |
| 186 | 248 | ||
| 249 | + | func requestIDFromBridgePayload(payload []byte) string { | |
| 250 | + | if len(bytes.TrimSpace(payload)) == 0 { | |
| 251 | + | return "" | |
| 252 | + | } | |
| 253 | + | var envelope struct { | |
| 254 | + | RequestID string `json:"requestId"` | |
| 255 | + | } | |
| 256 | + | if err := json.Unmarshal(payload, &envelope); err != nil { | |
| 257 | + | return "" | |
| 258 | + | } | |
| 259 | + | return envelope.RequestID | |
| 260 | + | } | |
| 261 | + | ||
| 187 | 262 | func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) { | |
| 188 | - | response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint)) | |
| 263 | + | fetch := js.Global().Get("fetch") | |
| 264 | + | if !fetch.Truthy() { | |
| 265 | + | return nil, errors.New("fetch API not available") | |
| 266 | + | } | |
| 267 | + | response, err := awaitPromise(ctx, fetch.Invoke(endpoint)) | |
| 189 | 268 | if err != nil { | |
| 190 | 269 | return nil, err | |
| 191 | 270 | } | |
| @@ -243,43 +322,77 @@ func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, err | |||
| 243 | 322 | } | |
| 244 | 323 | ||
| 245 | 324 | func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | |
| 325 | + | if ctx == nil { | |
| 326 | + | ctx = context.Background() | |
| 327 | + | } | |
| 246 | 328 | type result struct { | |
| 247 | 329 | value js.Value | |
| 248 | 330 | err error | |
| 249 | 331 | } | |
| 250 | 332 | resultCh := make(chan result, 1) | |
| 251 | 333 | ||
| 334 | + | promiseCtor := js.Global().Get("Promise") | |
| 335 | + | if !promiseCtor.Truthy() { | |
| 336 | + | return js.Undefined(), errors.New("Promise global not available") | |
| 337 | + | } | |
| 338 | + | var ( | |
| 339 | + | cancelMu sync.Mutex | |
| 340 | + | reject = js.Undefined() | |
| 341 | + | ) | |
| 342 | + | executor := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 343 | + | if len(args) >= 2 { | |
| 344 | + | cancelMu.Lock() | |
| 345 | + | reject = args[1] | |
| 346 | + | cancelMu.Unlock() | |
| 347 | + | } | |
| 348 | + | return nil | |
| 349 | + | }) | |
| 350 | + | cancelPromise := promiseCtor.New(executor) | |
| 351 | + | executor.Release() | |
| 352 | + | raceInput := js.Global().Get("Array").New(promise, cancelPromise) | |
| 353 | + | racePromise := promiseCtor.Call("race", raceInput) | |
| 354 | + | ||
| 355 | + | done := make(chan struct{}) | |
| 356 | + | go func() { | |
| 357 | + | select { | |
| 358 | + | case <-ctx.Done(): | |
| 359 | + | cancelMu.Lock() | |
| 360 | + | rejectFn := reject | |
| 361 | + | cancelMu.Unlock() | |
| 362 | + | if rejectFn.Truthy() { | |
| 363 | + | rejectFn.Invoke(ctx.Err().Error()) | |
| 364 | + | } | |
| 365 | + | case <-done: | |
| 366 | + | } | |
| 367 | + | }() | |
| 368 | + | defer close(done) | |
| 369 | + | ||
| 252 | 370 | var ( | |
| 253 | 371 | callbackMu sync.Mutex | |
| 254 | - | active = true | |
| 255 | 372 | released bool | |
| 256 | - | thenFn js.Func | |
| 257 | - | catchFn js.Func | |
| 373 | + | thenCb js.Func | |
| 374 | + | catchCb js.Func | |
| 258 | 375 | ) | |
| 259 | - | releaseCallbacksLocked := func() { | |
| 376 | + | releaseCallbacks := func() { | |
| 377 | + | callbackMu.Lock() | |
| 378 | + | defer callbackMu.Unlock() | |
| 260 | 379 | if released { | |
| 261 | 380 | return | |
| 262 | 381 | } | |
| 263 | 382 | released = true | |
| 264 | - | thenFn.Release() | |
| 265 | - | catchFn.Release() | |
| 383 | + | thenCb.Release() | |
| 384 | + | catchCb.Release() | |
| 266 | 385 | } | |
| 267 | 386 | publish := func(res result) { | |
| 268 | - | callbackMu.Lock() | |
| 269 | - | defer callbackMu.Unlock() | |
| 270 | - | if !active { | |
| 271 | - | releaseCallbacksLocked() | |
| 272 | - | return | |
| 273 | - | } | |
| 274 | - | active = false | |
| 275 | 387 | select { | |
| 276 | 388 | case resultCh <- res: | |
| 277 | 389 | default: | |
| 390 | + | warnDroppedBridgeResult("promise", res.err) | |
| 278 | 391 | } | |
| 279 | - | releaseCallbacksLocked() | |
| 280 | 392 | } | |
| 281 | 393 | ||
| 282 | - | thenFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 394 | + | thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 395 | + | defer releaseCallbacks() | |
| 283 | 396 | if len(args) == 0 { | |
| 284 | 397 | publish(result{value: js.Undefined()}) | |
| 285 | 398 | return nil | |
| @@ -287,31 +400,40 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | |||
| 287 | 400 | publish(result{value: args[0]}) | |
| 288 | 401 | return nil | |
| 289 | 402 | }) | |
| 290 | - | catchFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 403 | + | catchCb = js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 404 | + | defer releaseCallbacks() | |
| 291 | 405 | if len(args) == 0 { | |
| 292 | 406 | publish(result{err: errors.New("promise rejected")}) | |
| 293 | 407 | return nil | |
| 294 | 408 | } | |
| 295 | - | publish(result{err: errors.New(args[0].String())}) | |
| 409 | + | message := args[0].String() | |
| 410 | + | if ctxErr := ctx.Err(); ctxErr != nil && message == ctxErr.Error() { | |
| 411 | + | publish(result{err: ctxErr}) | |
| 412 | + | return nil | |
| 413 | + | } | |
| 414 | + | publish(result{err: errors.New(message)}) | |
| 296 | 415 | return nil | |
| 297 | 416 | }) | |
| 417 | + | attachPromiseCallbacks(racePromise, thenCb, catchCb) | |
| 298 | 418 | ||
| 299 | - | promise.Call("then", thenFn).Call("catch", catchFn) | |
| 419 | + | res := <-resultCh | |
| 420 | + | return res.value, res.err | |
| 421 | + | } | |
| 300 | 422 | ||
| 301 | - | select { | |
| 302 | - | case res := <-resultCh: | |
| 303 | - | return res.value, res.err | |
| 304 | - | case <-ctx.Done(): | |
| 305 | - | select { | |
| 306 | - | case res := <-resultCh: | |
| 307 | - | return res.value, res.err | |
| 308 | - | default: | |
| 309 | - | } | |
| 310 | - | callbackMu.Lock() | |
| 311 | - | active = false | |
| 312 | - | callbackMu.Unlock() | |
| 313 | - | return js.Undefined(), ctx.Err() | |
| 423 | + | func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) { | |
| 424 | + | promise.Call("then", thenCb, catchCb) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func warnDroppedBridgeResult(scope string, err error) { | |
| 428 | + | console := js.Global().Get("console") | |
| 429 | + | if !console.Truthy() || !console.Get("warn").Truthy() { | |
| 430 | + | return | |
| 314 | 431 | } | |
| 432 | + | if err != nil { | |
| 433 | + | console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error()) | |
| 434 | + | return | |
| 435 | + | } | |
| 436 | + | console.Call("warn", "dumber systemviews bridge dropped "+scope+" result") | |
| 315 | 437 | } | |
| 316 | 438 | ||
| 317 | 439 | func stringifyArgs(args []js.Value) ([]byte, error) { | |
| @@ -327,8 +449,11 @@ func stringifyValue(value js.Value) ([]byte, error) { | |||
| 327 | 449 | return nil, errors.New("JSON global not available") | |
| 328 | 450 | } | |
| 329 | 451 | stringified := jsonValue.Call("stringify", value) | |
| 330 | - | if !stringified.Truthy() { | |
| 331 | - | return nil, errors.New("failed to stringify JS value") | |
| 452 | + | if stringified.Type() == js.TypeString { | |
| 453 | + | return []byte(stringified.String()), nil | |
| 454 | + | } | |
| 455 | + | if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) { | |
| 456 | + | return []byte("null"), nil | |
| 332 | 457 | } | |
| 333 | - | return []byte(stringified.String()), nil | |
| 458 | + | return nil, errors.New("failed to stringify JS value") | |
| 334 | 459 | } | |
M internal/infrastructure/systemviewsbridge/browser_transport_test.go +78 -0
| @@ -83,6 +83,40 @@ func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) { | |||
| 83 | 83 | } | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | func TestNormalizeBridgeShimResponse_DoesNotTreatRequestIDDataAsEnvelope(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | ||
| 89 | + | got, err := normalizeBridgeShimResponse("req-raw", []byte(`{"data":{"requestId":"external-id","title":"Example"},"_callback":"__dumber_homepage_response"}`)) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | var resp bridgeResponse | |
| 95 | + | if err := json.Unmarshal(got, &resp); err != nil { | |
| 96 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |
| 97 | + | } | |
| 98 | + | if string(resp.Data) != `{"requestId":"external-id","title":"Example"}` { | |
| 99 | + | t.Fatalf("data = %s, want raw payload", resp.Data) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestNormalizeBridgeShimResponse_DoesNotTreatNonBooleanSuccessDataAsEnvelope(t *testing.T) { | |
| 104 | + | t.Parallel() | |
| 105 | + | ||
| 106 | + | got, err := normalizeBridgeShimResponse("req-raw-success", []byte(`{"data":{"success":"yes","title":"Example"},"_callback":"__dumber_homepage_response"}`)) | |
| 107 | + | if err != nil { | |
| 108 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | var resp bridgeResponse | |
| 112 | + | if err := json.Unmarshal(got, &resp); err != nil { | |
| 113 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |
| 114 | + | } | |
| 115 | + | if string(resp.Data) != `{"success":"yes","title":"Example"}` { | |
| 116 | + | t.Fatalf("data = %s, want raw payload", resp.Data) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 86 | 120 | func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | |
| 87 | 121 | t.Parallel() | |
| 88 | 122 | ||
| @@ -106,6 +140,50 @@ func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | |||
| 106 | 140 | } | |
| 107 | 141 | } | |
| 108 | 142 | ||
| 143 | + | func TestNormalizeBridgeShimResponse_WrapsTopLevelErrorWithoutData(t *testing.T) { | |
| 144 | + | t.Parallel() | |
| 145 | + | ||
| 146 | + | got, err := normalizeBridgeShimResponse("req-error", []byte(`{"error":"bridge failed"}`)) | |
| 147 | + | if err != nil { | |
| 148 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | var resp bridgeResponse | |
| 152 | + | if err := json.Unmarshal(got, &resp); err != nil { | |
| 153 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |
| 154 | + | } | |
| 155 | + | if resp.RequestID != "req-error" { | |
| 156 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-error") | |
| 157 | + | } | |
| 158 | + | if resp.Error != "bridge failed" { | |
| 159 | + | t.Fatalf("error = %q, want %q", resp.Error, "bridge failed") | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func TestNormalizeBridgeShimResponse_RejectsMalformedJSON(t *testing.T) { | |
| 164 | + | t.Parallel() | |
| 165 | + | ||
| 166 | + | if _, err := normalizeBridgeShimResponse("req-bad", []byte(`{"data":`)); err == nil { | |
| 167 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestNormalizeBridgeShimResponse_RejectsMissingData(t *testing.T) { | |
| 172 | + | t.Parallel() | |
| 173 | + | ||
| 174 | + | if _, err := normalizeBridgeShimResponse("req-missing", []byte(`{"_callback":"__dumber_config_saved"}`)); err == nil { | |
| 175 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestNormalizeBridgeShimResponse_RejectsEmptyBody(t *testing.T) { | |
| 180 | + | t.Parallel() | |
| 181 | + | ||
| 182 | + | if _, err := normalizeBridgeShimResponse("req-empty", nil); err == nil { | |
| 183 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 109 | 187 | func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) { | |
| 110 | 188 | t.Parallel() | |
| 111 | 189 | ||
M internal/infrastructure/systemviewsbridge/client.go +138 -27
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "strings" | |
| 8 | 9 | "sync/atomic" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -20,8 +21,9 @@ type Transport interface { | |||
| 20 | 21 | ||
| 21 | 22 | // Client sends WebUI message envelopes through the first available transport. | |
| 22 | 23 | type Client struct { | |
| 23 | - | native Transport | |
| 24 | - | fetch Transport | |
| 24 | + | native Transport | |
| 25 | + | fetch Transport | |
| 26 | + | requestTimeout time.Duration | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | var _ port.SystemviewConfigService = (*Client)(nil) | |
| @@ -30,11 +32,27 @@ var _ port.SystemviewFavoritesService = (*Client)(nil) | |||
| 30 | 32 | ||
| 31 | 33 | var requestSeq atomic.Uint64 | |
| 32 | 34 | ||
| 33 | - | var bridgeRequestTimeout = 15 * time.Second | |
| 35 | + | const defaultBridgeRequestTimeout = 15 * time.Second | |
| 36 | + | ||
| 37 | + | // ClientOption customizes a bridge client. | |
| 38 | + | type ClientOption func(*Client) | |
| 39 | + | ||
| 40 | + | // WithRequestTimeout sets the timeout used when callers provide no deadline. | |
| 41 | + | func WithRequestTimeout(timeout time.Duration) ClientOption { | |
| 42 | + | return func(c *Client) { | |
| 43 | + | c.requestTimeout = timeout | |
| 44 | + | } | |
| 45 | + | } | |
| 34 | 46 | ||
| 35 | 47 | // NewClient creates a bridge client with native WebKit and fetch transports. | |
| 36 | - | func NewClient(native, fetch Transport) *Client { | |
| 37 | - | return &Client{native: native, fetch: fetch} | |
| 48 | + | func NewClient(native, fetch Transport, opts ...ClientOption) *Client { | |
| 49 | + | client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout} | |
| 50 | + | for _, opt := range opts { | |
| 51 | + | if opt != nil { | |
| 52 | + | opt(client) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | return client | |
| 38 | 56 | } | |
| 39 | 57 | ||
| 40 | 58 | // Send builds a WebUI message envelope and sends it through the active transport. | |
| @@ -57,6 +75,12 @@ func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, | |||
| 57 | 75 | } | |
| 58 | 76 | ||
| 59 | 77 | func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 78 | + | if limit < 0 { | |
| 79 | + | return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit) | |
| 80 | + | } | |
| 81 | + | if offset < 0 { | |
| 82 | + | return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset) | |
| 83 | + | } | |
| 60 | 84 | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct { | |
| 61 | 85 | RequestID string `json:"requestId"` | |
| 62 | 86 | Limit int `json:"limit"` | |
| @@ -65,6 +89,16 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His | |||
| 65 | 89 | } | |
| 66 | 90 | ||
| 67 | 91 | func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |
| 92 | + | domain = strings.TrimSpace(domain) | |
| 93 | + | if domain == "" { | |
| 94 | + | return nil, fmt.Errorf("timeline-by-domain domain must be non-empty") | |
| 95 | + | } | |
| 96 | + | if limit < 0 { | |
| 97 | + | return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit) | |
| 98 | + | } | |
| 99 | + | if offset < 0 { | |
| 100 | + | return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset) | |
| 101 | + | } | |
| 68 | 102 | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct { | |
| 69 | 103 | RequestID string `json:"requestId"` | |
| 70 | 104 | Domain string `json:"domain"` | |
| @@ -74,6 +108,9 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||
| 74 | 108 | } | |
| 75 | 109 | ||
| 76 | 110 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |
| 111 | + | if limit < 0 { | |
| 112 | + | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | |
| 113 | + | } | |
| 77 | 114 | return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct { | |
| 78 | 115 | RequestID string `json:"requestId"` | |
| 79 | 116 | Query string `json:"query"` | |
| @@ -82,6 +119,9 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity | |||
| 82 | 119 | } | |
| 83 | 120 | ||
| 84 | 121 | func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | |
| 122 | + | if id <= 0 { | |
| 123 | + | return fmt.Errorf("delete entry id must be positive, got %d", id) | |
| 124 | + | } | |
| 85 | 125 | _, err := request[struct{}](c, ctx, "history_delete_entry", struct { | |
| 86 | 126 | RequestID string `json:"requestId"` | |
| 87 | 127 | ID int64 `json:"id"` | |
| @@ -90,6 +130,10 @@ func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | |||
| 90 | 130 | } | |
| 91 | 131 | ||
| 92 | 132 | func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | |
| 133 | + | rangeID = strings.TrimSpace(rangeID) | |
| 134 | + | if rangeID == "" { | |
| 135 | + | return errors.New("history range ID cannot be empty") | |
| 136 | + | } | |
| 93 | 137 | _, err := request[struct{}](c, ctx, "history_delete_range", struct { | |
| 94 | 138 | RequestID string `json:"requestId"` | |
| 95 | 139 | Range string `json:"range"` | |
| @@ -97,12 +141,16 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | |||
| 97 | 141 | return err | |
| 98 | 142 | } | |
| 99 | 143 | ||
| 144 | + | // Current intentionally targets the direct HTTP config endpoint rather than a | |
| 145 | + | // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config. | |
| 100 | 146 | func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 101 | 147 | return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct { | |
| 102 | 148 | RequestID string `json:"requestId"` | |
| 103 | 149 | }{RequestID: nextRequestID()}) | |
| 104 | 150 | } | |
| 105 | 151 | ||
| 152 | + | // Default intentionally targets the direct HTTP config endpoint rather than a | |
| 153 | + | // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default. | |
| 106 | 154 | func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 107 | 155 | return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct { | |
| 108 | 156 | RequestID string `json:"requestId"` | |
| @@ -143,6 +191,9 @@ func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error | |||
| 143 | 191 | } | |
| 144 | 192 | ||
| 145 | 193 | func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) { | |
| 194 | + | if limit < 0 { | |
| 195 | + | return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit) | |
| 196 | + | } | |
| 146 | 197 | return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct { | |
| 147 | 198 | RequestID string `json:"requestId"` | |
| 148 | 199 | Limit int `json:"limit"` | |
| @@ -150,6 +201,10 @@ func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainSt | |||
| 150 | 201 | } | |
| 151 | 202 | ||
| 152 | 203 | func (c *Client) DeleteDomain(ctx context.Context, domain string) error { | |
| 204 | + | domain = strings.TrimSpace(domain) | |
| 205 | + | if domain == "" { | |
| 206 | + | return errors.New("history domain cannot be empty") | |
| 207 | + | } | |
| 153 | 208 | _, err := request[struct{}](c, ctx, "history_delete_domain", struct { | |
| 154 | 209 | RequestID string `json:"requestId"` | |
| 155 | 210 | Domain string `json:"domain"` | |
| @@ -168,10 +223,16 @@ func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateIn | |||
| 168 | 223 | } | |
| 169 | 224 | ||
| 170 | 225 | func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | |
| 226 | + | if input.ID <= 0 { | |
| 227 | + | return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID) | |
| 228 | + | } | |
| 171 | 229 | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | |
| 172 | 230 | } | |
| 173 | 231 | ||
| 174 | 232 | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | |
| 233 | + | if id <= 0 { | |
| 234 | + | return fmt.Errorf("delete favorite id must be positive, got %d", id) | |
| 235 | + | } | |
| 175 | 236 | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | |
| 176 | 237 | RequestID string `json:"requestId"` | |
| 177 | 238 | ID int64 `json:"id"` | |
| @@ -244,6 +305,9 @@ func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) { | |||
| 244 | 305 | } | |
| 245 | 306 | ||
| 246 | 307 | func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error { | |
| 308 | + | if favoriteID <= 0 { | |
| 309 | + | return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID) | |
| 310 | + | } | |
| 247 | 311 | _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct { | |
| 248 | 312 | RequestID string `json:"requestId"` | |
| 249 | 313 | FavoriteID int64 `json:"favorite_id"` | |
| @@ -253,6 +317,9 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey | |||
| 253 | 317 | } | |
| 254 | 318 | ||
| 255 | 319 | func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error { | |
| 320 | + | if favoriteID <= 0 { | |
| 321 | + | return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID) | |
| 322 | + | } | |
| 256 | 323 | _, err := request[struct{}](c, ctx, "favorite_set_folder", struct { | |
| 257 | 324 | RequestID string `json:"requestId"` | |
| 258 | 325 | FavoriteID int64 `json:"favorite_id"` | |
| @@ -261,19 +328,34 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6 | |||
| 261 | 328 | return err | |
| 262 | 329 | } | |
| 263 | 330 | ||
| 264 | - | func (c *Client) CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) { | |
| 331 | + | func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) { | |
| 332 | + | name = strings.TrimSpace(name) | |
| 333 | + | if name == "" { | |
| 334 | + | return nil, fmt.Errorf("folder name is required") | |
| 335 | + | } | |
| 336 | + | var iconPtr *string | |
| 337 | + | if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" { | |
| 338 | + | iconPtr = &trimmedIcon | |
| 339 | + | } | |
| 265 | 340 | return request[*entity.Folder](c, ctx, "folder_create", struct { | |
| 266 | 341 | RequestID string `json:"requestId"` | |
| 267 | 342 | Name string `json:"name"` | |
| 268 | 343 | Icon *string `json:"icon"` | |
| 269 | 344 | ParentID *int64 `json:"parent_id,omitempty"` | |
| 270 | - | }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID}) | |
| 345 | + | }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID}) | |
| 271 | 346 | } | |
| 272 | 347 | ||
| 273 | 348 | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | |
| 349 | + | if id <= 0 { | |
| 350 | + | return fmt.Errorf("update folder id must be positive, got %d", id) | |
| 351 | + | } | |
| 352 | + | name = strings.TrimSpace(name) | |
| 353 | + | if name == "" { | |
| 354 | + | return fmt.Errorf("folder name is required") | |
| 355 | + | } | |
| 274 | 356 | var iconPtr *string | |
| 275 | - | if icon != "" { | |
| 276 | - | iconPtr = &icon | |
| 357 | + | if trimmed := strings.TrimSpace(icon); trimmed != "" { | |
| 358 | + | iconPtr = &trimmed | |
| 277 | 359 | } | |
| 278 | 360 | _, err := request[struct{}](c, ctx, "folder_update", struct { | |
| 279 | 361 | RequestID string `json:"requestId"` | |
| @@ -285,6 +367,9 @@ func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) | |||
| 285 | 367 | } | |
| 286 | 368 | ||
| 287 | 369 | func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | |
| 370 | + | if id <= 0 { | |
| 371 | + | return fmt.Errorf("delete folder id must be positive, got %d", id) | |
| 372 | + | } | |
| 288 | 373 | _, err := request[struct{}](c, ctx, "folder_delete", struct { | |
| 289 | 374 | RequestID string `json:"requestId"` | |
| 290 | 375 | ID int64 `json:"id"` | |
| @@ -293,9 +378,13 @@ func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | |||
| 293 | 378 | } | |
| 294 | 379 | ||
| 295 | 380 | func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) { | |
| 381 | + | name = strings.TrimSpace(name) | |
| 382 | + | if name == "" { | |
| 383 | + | return nil, fmt.Errorf("tag name is required") | |
| 384 | + | } | |
| 296 | 385 | var colorPtr *string | |
| 297 | - | if color != "" { | |
| 298 | - | colorPtr = &color | |
| 386 | + | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | |
| 387 | + | colorPtr = &trimmedColor | |
| 299 | 388 | } | |
| 300 | 389 | return request[*entity.Tag](c, ctx, "tag_create", struct { | |
| 301 | 390 | RequestID string `json:"requestId"` | |
| @@ -305,13 +394,16 @@ func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag | |||
| 305 | 394 | } | |
| 306 | 395 | ||
| 307 | 396 | func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error { | |
| 397 | + | if id <= 0 { | |
| 398 | + | return fmt.Errorf("update tag id must be positive, got %d", id) | |
| 399 | + | } | |
| 308 | 400 | var namePtr *string | |
| 309 | - | if name != "" { | |
| 310 | - | namePtr = &name | |
| 401 | + | if trimmedName := strings.TrimSpace(name); trimmedName != "" { | |
| 402 | + | namePtr = &trimmedName | |
| 311 | 403 | } | |
| 312 | 404 | var colorPtr *string | |
| 313 | - | if color != "" { | |
| 314 | - | colorPtr = &color | |
| 405 | + | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | |
| 406 | + | colorPtr = &trimmedColor | |
| 315 | 407 | } | |
| 316 | 408 | _, err := request[struct{}](c, ctx, "tag_update", struct { | |
| 317 | 409 | RequestID string `json:"requestId"` | |
| @@ -323,6 +415,9 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er | |||
| 323 | 415 | } | |
| 324 | 416 | ||
| 325 | 417 | func (c *Client) DeleteTag(ctx context.Context, id int64) error { | |
| 418 | + | if id <= 0 { | |
| 419 | + | return fmt.Errorf("delete tag id must be positive, got %d", id) | |
| 420 | + | } | |
| 326 | 421 | _, err := request[struct{}](c, ctx, "tag_delete", struct { | |
| 327 | 422 | RequestID string `json:"requestId"` | |
| 328 | 423 | ID int64 `json:"id"` | |
| @@ -331,6 +426,12 @@ func (c *Client) DeleteTag(ctx context.Context, id int64) error { | |||
| 331 | 426 | } | |
| 332 | 427 | ||
| 333 | 428 | func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | |
| 429 | + | if favoriteID <= 0 { | |
| 430 | + | return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID) | |
| 431 | + | } | |
| 432 | + | if tagID <= 0 { | |
| 433 | + | return fmt.Errorf("assign tag id must be positive, got %d", tagID) | |
| 434 | + | } | |
| 334 | 435 | _, err := request[struct{}](c, ctx, "tag_assign", struct { | |
| 335 | 436 | RequestID string `json:"requestId"` | |
| 336 | 437 | FavoriteID int64 `json:"favorite_id"` | |
| @@ -340,6 +441,12 @@ func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | |||
| 340 | 441 | } | |
| 341 | 442 | ||
| 342 | 443 | func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error { | |
| 444 | + | if favoriteID <= 0 { | |
| 445 | + | return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID) | |
| 446 | + | } | |
| 447 | + | if tagID <= 0 { | |
| 448 | + | return fmt.Errorf("remove tag id must be positive, got %d", tagID) | |
| 449 | + | } | |
| 343 | 450 | _, err := request[struct{}](c, ctx, "tag_remove", struct { | |
| 344 | 451 | RequestID string `json:"requestId"` | |
| 345 | 452 | FavoriteID int64 `json:"favorite_id"` | |
| @@ -364,7 +471,7 @@ func (c *Client) transport() Transport { | |||
| 364 | 471 | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | |
| 365 | 472 | var zero T | |
| 366 | 473 | ||
| 367 | - | ctx, cancel := withBridgeRequestTimeout(ctx) | |
| 474 | + | ctx, cancel := c.withBridgeRequestTimeout(ctx) | |
| 368 | 475 | defer cancel() | |
| 369 | 476 | ||
| 370 | 477 | raw, err := c.Send(ctx, msgType, payload) | |
| @@ -375,14 +482,18 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any) | |||
| 375 | 482 | return decodeBridgeResponse[T](raw) | |
| 376 | 483 | } | |
| 377 | 484 | ||
| 378 | - | func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { | |
| 485 | + | func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { | |
| 379 | 486 | if ctx == nil { | |
| 380 | 487 | ctx = context.Background() | |
| 381 | 488 | } | |
| 382 | - | if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 { | |
| 489 | + | timeout := defaultBridgeRequestTimeout | |
| 490 | + | if c != nil { | |
| 491 | + | timeout = c.requestTimeout | |
| 492 | + | } | |
| 493 | + | if _, ok := ctx.Deadline(); ok || timeout <= 0 { | |
| 383 | 494 | return context.WithCancel(ctx) | |
| 384 | 495 | } | |
| 385 | - | return context.WithTimeout(ctx, bridgeRequestTimeout) | |
| 496 | + | return context.WithTimeout(ctx, timeout) | |
| 386 | 497 | } | |
| 387 | 498 | ||
| 388 | 499 | type bridgeResponse struct { | |
| @@ -393,27 +504,27 @@ type bridgeResponse struct { | |||
| 393 | 504 | } | |
| 394 | 505 | ||
| 395 | 506 | func decodeBridgeResponse[T any](body []byte) (T, error) { | |
| 396 | - | var zero T | |
| 507 | + | var result T | |
| 397 | 508 | ||
| 398 | 509 | var resp bridgeResponse | |
| 399 | 510 | if err := json.Unmarshal(body, &resp); err != nil { | |
| 400 | - | return zero, fmt.Errorf("unmarshal bridge response: %w", err) | |
| 511 | + | return result, fmt.Errorf("unmarshal bridge response: %w", err) | |
| 401 | 512 | } | |
| 402 | 513 | if !resp.Success { | |
| 403 | 514 | if resp.Error == "" { | |
| 404 | - | return zero, errors.New("bridge request failed") | |
| 515 | + | return result, errors.New("bridge request failed") | |
| 405 | 516 | } | |
| 406 | - | return zero, errors.New(resp.Error) | |
| 517 | + | return result, errors.New(resp.Error) | |
| 407 | 518 | } | |
| 408 | 519 | if len(resp.Data) == 0 { | |
| 409 | - | return zero, nil | |
| 520 | + | return result, nil | |
| 410 | 521 | } | |
| 411 | 522 | ||
| 412 | - | if err := json.Unmarshal(resp.Data, &zero); err != nil { | |
| 413 | - | return zero, fmt.Errorf("unmarshal bridge data: %w", err) | |
| 523 | + | if err := json.Unmarshal(resp.Data, &result); err != nil { | |
| 524 | + | return result, fmt.Errorf("unmarshal bridge data: %w", err) | |
| 414 | 525 | } | |
| 415 | 526 | ||
| 416 | - | return zero, nil | |
| 527 | + | return result, nil | |
| 417 | 528 | } | |
| 418 | 529 | ||
| 419 | 530 | func nextRequestID() string { | |
M internal/infrastructure/systemviewsbridge/client_test.go +227 -8
| @@ -126,6 +126,21 @@ func TestClientTimelineDecodesEntries(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | + | func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) { | |
| 130 | + | t.Parallel() | |
| 131 | + | ||
| 132 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)} | |
| 133 | + | client := NewClient(transport, nil) | |
| 134 | + | ||
| 135 | + | err := client.DeleteRange(context.Background(), " ") | |
| 136 | + | if err == nil { | |
| 137 | + | t.Fatal("DeleteRange() error = nil, want validation error") | |
| 138 | + | } | |
| 139 | + | if transport.called { | |
| 140 | + | t.Fatal("DeleteRange() sent a request for an invalid range") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 129 | 144 | func TestClientDeleteRangeSendsRange(t *testing.T) { | |
| 130 | 145 | t.Parallel() | |
| 131 | 146 | ||
| @@ -357,12 +372,10 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 357 | 372 | } | |
| 358 | 373 | ||
| 359 | 374 | func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | |
| 360 | - | oldTimeout := bridgeRequestTimeout | |
| 361 | - | bridgeRequestTimeout = time.Millisecond | |
| 362 | - | t.Cleanup(func() { bridgeRequestTimeout = oldTimeout }) | |
| 375 | + | t.Parallel() | |
| 363 | 376 | ||
| 364 | 377 | transport := &blockingTransport{available: true} | |
| 365 | - | client := NewClient(transport, nil) | |
| 378 | + | client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond)) | |
| 366 | 379 | ||
| 367 | 380 | _, err := client.Timeline(context.Background(), 25, 0) | |
| 368 | 381 | if !errors.Is(err, context.DeadlineExceeded) { | |
| @@ -377,10 +390,10 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||
| 377 | 390 | t.Parallel() | |
| 378 | 391 | ||
| 379 | 392 | parentID := int64(42) | |
| 380 | - | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)} | |
| 393 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)} | |
| 381 | 394 | client := NewClient(transport, nil) | |
| 382 | 395 | ||
| 383 | - | folder, err := client.CreateFolder(context.Background(), "Nested", &parentID) | |
| 396 | + | folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID) | |
| 384 | 397 | if err != nil { | |
| 385 | 398 | t.Fatalf("CreateFolder() error = %v", err) | |
| 386 | 399 | } | |
| @@ -390,6 +403,9 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||
| 390 | 403 | if folder.Name != "Nested" { | |
| 391 | 404 | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | |
| 392 | 405 | } | |
| 406 | + | if folder.Icon != "📁" { | |
| 407 | + | t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon) | |
| 408 | + | } | |
| 393 | 409 | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | |
| 394 | 410 | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | |
| 395 | 411 | } | |
| @@ -417,14 +433,155 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||
| 417 | 433 | if payload.Name != "Nested" { | |
| 418 | 434 | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | |
| 419 | 435 | } | |
| 420 | - | if payload.Icon != nil { | |
| 421 | - | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |
| 436 | + | if payload.Icon == nil || *payload.Icon != "📁" { | |
| 437 | + | t.Fatalf("sent icon = %#v, want 📁", payload.Icon) | |
| 422 | 438 | } | |
| 423 | 439 | if payload.RequestID == "" { | |
| 424 | 440 | t.Fatal("sent requestId was empty") | |
| 425 | 441 | } | |
| 426 | 442 | } | |
| 427 | 443 | ||
| 444 | + | func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) { | |
| 445 | + | t.Parallel() | |
| 446 | + | ||
| 447 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)} | |
| 448 | + | client := NewClient(transport, nil) | |
| 449 | + | ||
| 450 | + | _, err := client.DomainStats(context.Background(), -1) | |
| 451 | + | if err == nil { | |
| 452 | + | t.Fatal("DomainStats() error = nil, want validation error") | |
| 453 | + | } | |
| 454 | + | if transport.called { | |
| 455 | + | t.Fatal("DomainStats() sent a request for a negative limit") | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) { | |
| 460 | + | t.Parallel() | |
| 461 | + | ||
| 462 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)} | |
| 463 | + | client := NewClient(transport, nil) | |
| 464 | + | ||
| 465 | + | err := client.DeleteDomain(context.Background(), " ") | |
| 466 | + | if err == nil { | |
| 467 | + | t.Fatal("DeleteDomain() error = nil, want validation error") | |
| 468 | + | } | |
| 469 | + | if transport.called { | |
| 470 | + | t.Fatal("DeleteDomain() sent a request for an empty domain") | |
| 471 | + | } | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func TestClientUpdateFolderTrimsIcon(t *testing.T) { | |
| 475 | + | t.Parallel() | |
| 476 | + | ||
| 477 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","success":true}`)} | |
| 478 | + | client := NewClient(transport, nil) | |
| 479 | + | ||
| 480 | + | if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil { | |
| 481 | + | t.Fatalf("UpdateFolder() error = %v", err) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | var msg port.WebUIMessage | |
| 485 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |
| 486 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |
| 487 | + | } | |
| 488 | + | if msg.Type != "folder_update" { | |
| 489 | + | t.Fatalf("sent type = %q, want folder_update", msg.Type) | |
| 490 | + | } | |
| 491 | + | var payload struct { | |
| 492 | + | Icon *string `json:"icon"` | |
| 493 | + | } | |
| 494 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |
| 495 | + | t.Fatalf("unmarshal sent payload: %v", err) | |
| 496 | + | } | |
| 497 | + | if payload.Icon == nil || *payload.Icon != "📚" { | |
| 498 | + | t.Fatalf("sent icon = %#v, want 📚", payload.Icon) | |
| 499 | + | } | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) { | |
| 503 | + | t.Parallel() | |
| 504 | + | ||
| 505 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)} | |
| 506 | + | client := NewClient(transport, nil) | |
| 507 | + | ||
| 508 | + | if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil { | |
| 509 | + | t.Fatalf("UpdateFolder() error = %v", err) | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | var msg port.WebUIMessage | |
| 513 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |
| 514 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |
| 515 | + | } | |
| 516 | + | var payload struct { | |
| 517 | + | Icon *string `json:"icon"` | |
| 518 | + | } | |
| 519 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |
| 520 | + | t.Fatalf("unmarshal sent payload: %v", err) | |
| 521 | + | } | |
| 522 | + | if payload.Icon != nil { | |
| 523 | + | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |
| 524 | + | } | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func TestClientDeleteEntryRejectsInvalidID(t *testing.T) { | |
| 528 | + | t.Parallel() | |
| 529 | + | ||
| 530 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)} | |
| 531 | + | client := NewClient(transport, nil) | |
| 532 | + | ||
| 533 | + | err := client.DeleteEntry(context.Background(), 0) | |
| 534 | + | if err == nil { | |
| 535 | + | t.Fatal("DeleteEntry() error = nil, want validation error") | |
| 536 | + | } | |
| 537 | + | if transport.called { | |
| 538 | + | t.Fatal("DeleteEntry() sent a request for an invalid id") | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | func TestClientTagActionsTrimFields(t *testing.T) { | |
| 543 | + | t.Parallel() | |
| 544 | + | ||
| 545 | + | createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)} | |
| 546 | + | client := NewClient(createTransport, nil) | |
| 547 | + | if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil { | |
| 548 | + | t.Fatalf("CreateTag() error = %v", err) | |
| 549 | + | } | |
| 550 | + | var msg port.WebUIMessage | |
| 551 | + | if err := json.Unmarshal(createTransport.last, &msg); err != nil { | |
| 552 | + | t.Fatalf("unmarshal create envelope: %v", err) | |
| 553 | + | } | |
| 554 | + | var createPayload struct { | |
| 555 | + | Name string `json:"name"` | |
| 556 | + | Color *string `json:"color"` | |
| 557 | + | } | |
| 558 | + | if err := json.Unmarshal(msg.Payload, &createPayload); err != nil { | |
| 559 | + | t.Fatalf("unmarshal create payload: %v", err) | |
| 560 | + | } | |
| 561 | + | if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" { | |
| 562 | + | t.Fatalf("CreateTag payload = %#v", createPayload) | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)} | |
| 566 | + | client = NewClient(updateTransport, nil) | |
| 567 | + | if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil { | |
| 568 | + | t.Fatalf("UpdateTag() error = %v", err) | |
| 569 | + | } | |
| 570 | + | if err := json.Unmarshal(updateTransport.last, &msg); err != nil { | |
| 571 | + | t.Fatalf("unmarshal update envelope: %v", err) | |
| 572 | + | } | |
| 573 | + | var updatePayload struct { | |
| 574 | + | Name *string `json:"name"` | |
| 575 | + | Color *string `json:"color"` | |
| 576 | + | } | |
| 577 | + | if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil { | |
| 578 | + | t.Fatalf("unmarshal update payload: %v", err) | |
| 579 | + | } | |
| 580 | + | if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil { | |
| 581 | + | t.Fatalf("UpdateTag payload = %#v", updatePayload) | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | ||
| 428 | 585 | type fakeTransport struct { | |
| 429 | 586 | available bool | |
| 430 | 587 | called bool | |
| @@ -452,3 +609,65 @@ func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) | |||
| 452 | 609 | <-ctx.Done() | |
| 453 | 610 | return nil, ctx.Err() | |
| 454 | 611 | } | |
| 612 | + | ||
| 613 | + | func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) { | |
| 614 | + | t.Parallel() | |
| 615 | + | ||
| 616 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)} | |
| 617 | + | client := NewClient(transport, nil) | |
| 618 | + | ||
| 619 | + | checks := []struct { | |
| 620 | + | name string | |
| 621 | + | call func() error | |
| 622 | + | }{ | |
| 623 | + | {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }}, | |
| 624 | + | {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }}, | |
| 625 | + | {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }}, | |
| 626 | + | {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }}, | |
| 627 | + | {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }}, | |
| 628 | + | {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }}, | |
| 629 | + | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | |
| 630 | + | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | |
| 631 | + | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | |
| 632 | + | {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }}, | |
| 633 | + | {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }}, | |
| 634 | + | {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }}, | |
| 635 | + | {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }}, | |
| 636 | + | {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }}, | |
| 637 | + | } | |
| 638 | + | for _, check := range checks { | |
| 639 | + | t.Run(check.name, func(t *testing.T) { | |
| 640 | + | if err := check.call(); err == nil { | |
| 641 | + | t.Fatal("error = nil, want validation error") | |
| 642 | + | } | |
| 643 | + | }) | |
| 644 | + | } | |
| 645 | + | if transport.called { | |
| 646 | + | t.Fatal("transport called for invalid favorite mutation IDs") | |
| 647 | + | } | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | func TestClientRejectsNegativeHistoryLimits(t *testing.T) { | |
| 651 | + | t.Parallel() | |
| 652 | + | ||
| 653 | + | client := NewClient(&fakeTransport{available: true}, nil) | |
| 654 | + | ||
| 655 | + | if _, err := client.Timeline(context.Background(), -1, 0); err == nil { | |
| 656 | + | t.Fatal("Timeline() error = nil, want error") | |
| 657 | + | } | |
| 658 | + | if _, err := client.Timeline(context.Background(), 1, -1); err == nil { | |
| 659 | + | t.Fatal("Timeline() error = nil, want error") | |
| 660 | + | } | |
| 661 | + | if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil { | |
| 662 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |
| 663 | + | } | |
| 664 | + | if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil { | |
| 665 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |
| 666 | + | } | |
| 667 | + | if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil { | |
| 668 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |
| 669 | + | } | |
| 670 | + | if _, err := client.Search(context.Background(), "example", -1); err == nil { | |
| 671 | + | t.Fatal("Search() error = nil, want error") | |
| 672 | + | } | |
| 673 | + | } | |
A internal/infrastructure/transcoder/http_client_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package transcoder | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | "net/http" | |
| 7 | + | "net/url" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/rs/zerolog" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) { | |
| 16 | + | source, err := url.Parse("http://127.0.0.1/video.mp4") | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |
| 22 | + | if err == nil { | |
| 23 | + | t.Fatal("validateHTTPTranscodeURL() expected error for loopback target") | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |
| 28 | + | tests := []string{ | |
| 29 | + | "100.64.0.1", | |
| 30 | + | "198.18.0.1", | |
| 31 | + | "192.0.2.1", | |
| 32 | + | "64:ff9b::a00:1", | |
| 33 | + | "2001::1", | |
| 34 | + | "2001:db8::1", | |
| 35 | + | "2002:0a00:0001::1", | |
| 36 | + | } | |
| 37 | + | for _, rawIP := range tests { | |
| 38 | + | t.Run(rawIP, func(t *testing.T) { | |
| 39 | + | if !isBlockedTranscodeIP(net.ParseIP(rawIP)) { | |
| 40 | + | t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP) | |
| 41 | + | } | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) { | |
| 47 | + | source, err := url.Parse("file:///etc/passwd") | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |
| 53 | + | if err == nil { | |
| 54 | + | t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) { | |
| 59 | + | source, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |
| 65 | + | if err == nil { | |
| 66 | + | t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) { | |
| 71 | + | source, err := url.Parse("https://93.184.216.34/video.mp4") | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | if err := validateHTTPTranscodeURL(context.Background(), source); err != nil { | |
| 77 | + | t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) { | |
| 82 | + | p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop()) | |
| 83 | + | ||
| 84 | + | _, _, err := p.openInputFormatContext(context.Background()) | |
| 85 | + | if err == nil { | |
| 86 | + | t.Fatal("openInputFormatContext() expected error for streaming manifest") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) { | |
| 91 | + | resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}} | |
| 92 | + | if !isStreamingManifestResponse(resp) { | |
| 93 | + | t.Fatal("isStreamingManifestResponse() = false, want true") | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) { | |
| 98 | + | requestURL, err := url.Parse("http://10.0.0.1/private.mp4") | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |
| 104 | + | if err == nil { | |
| 105 | + | t.Fatal("validateTranscodeRedirect() expected error for private redirect target") | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |
| 110 | + | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |
| 116 | + | if err == nil { | |
| 117 | + | t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target") | |
| 118 | + | } | |
| 119 | + | } | |
M internal/infrastructure/transcoder/pipeline.go +108 -5
| @@ -5,13 +5,13 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | + | "net" | |
| 8 | 9 | "net/http" | |
| 10 | + | "net/url" | |
| 9 | 11 | "sort" | |
| 10 | 12 | "strings" | |
| 11 | - | "unsafe" | |
| 12 | - | ||
| 13 | - | "net" | |
| 14 | 13 | "time" | |
| 14 | + | "unsafe" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/purego" | |
| 17 | 17 | "github.com/rs/zerolog" | |
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | |
| 20 | 20 | ||
| 21 | 21 | "github.com/bnema/dumber/internal/application/port" | |
| 22 | + | "github.com/bnema/dumber/internal/infrastructure/netguard" | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | 25 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | |
| @@ -53,10 +54,104 @@ type pipeline struct { | |||
| 53 | 54 | // are not killed. The session context handles cancellation. | |
| 54 | 55 | var httpClient = &http.Client{ | |
| 55 | 56 | Transport: &http.Transport{ | |
| 56 | - | DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, | |
| 57 | + | DialContext: safeTranscodeDialContext, | |
| 57 | 58 | TLSHandshakeTimeout: 10 * time.Second, | |
| 58 | 59 | ResponseHeaderTimeout: 15 * time.Second, | |
| 59 | 60 | }, | |
| 61 | + | CheckRedirect: validateTranscodeRedirect, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |
| 65 | + | if len(via) >= 10 { | |
| 66 | + | return fmt.Errorf("stopped after 10 redirects") | |
| 67 | + | } | |
| 68 | + | if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil { | |
| 69 | + | return fmt.Errorf("blocked redirect target: %w", err) | |
| 70 | + | } | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |
| 75 | + | host, port, err := net.SplitHostPort(address) | |
| 76 | + | if err != nil { | |
| 77 | + | return nil, err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | ips, err := resolveAllowedTranscodeIPs(ctx, host) | |
| 81 | + | if err != nil { | |
| 82 | + | return nil, err | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | dialer := &net.Dialer{Timeout: 10 * time.Second} | |
| 86 | + | var lastErr error | |
| 87 | + | for _, ip := range ips { | |
| 88 | + | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) | |
| 89 | + | if dialErr == nil { | |
| 90 | + | return conn, nil | |
| 91 | + | } | |
| 92 | + | lastErr = dialErr | |
| 93 | + | } | |
| 94 | + | if lastErr != nil { | |
| 95 | + | return nil, lastErr | |
| 96 | + | } | |
| 97 | + | return nil, fmt.Errorf("no allowed transcode source addresses for %s", host) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error { | |
| 101 | + | if source == nil || source.Host == "" { | |
| 102 | + | return fmt.Errorf("invalid src") | |
| 103 | + | } | |
| 104 | + | if source.Scheme != "http" && source.Scheme != "https" { | |
| 105 | + | return fmt.Errorf("unsupported src scheme") | |
| 106 | + | } | |
| 107 | + | if IsStreamingManifestURL(source.String()) { | |
| 108 | + | return fmt.Errorf("streaming manifest transcoding is disabled") | |
| 109 | + | } | |
| 110 | + | _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname()) | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) { | |
| 115 | + | host = strings.TrimSpace(host) | |
| 116 | + | if host == "" { | |
| 117 | + | return nil, fmt.Errorf("invalid src host") | |
| 118 | + | } | |
| 119 | + | if ip := net.ParseIP(host); ip != nil { | |
| 120 | + | if isBlockedTranscodeIP(ip) { | |
| 121 | + | return nil, fmt.Errorf("private src host not allowed") | |
| 122 | + | } | |
| 123 | + | return []net.IP{ip}, nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |
| 127 | + | defer cancel() | |
| 128 | + | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |
| 129 | + | if err != nil || len(addrs) == 0 { | |
| 130 | + | return nil, fmt.Errorf("src host could not be resolved") | |
| 131 | + | } | |
| 132 | + | ips := make([]net.IP, 0, len(addrs)) | |
| 133 | + | for _, addr := range addrs { | |
| 134 | + | if isBlockedTranscodeIP(addr.IP) { | |
| 135 | + | return nil, fmt.Errorf("private src host not allowed") | |
| 136 | + | } | |
| 137 | + | ips = append(ips, addr.IP) | |
| 138 | + | } | |
| 139 | + | return ips, nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func isBlockedTranscodeIP(ip net.IP) bool { | |
| 143 | + | return netguard.IsBlockedTranscodeIP(ip) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func isStreamingManifestResponse(resp *http.Response) bool { | |
| 147 | + | if resp == nil { | |
| 148 | + | return false | |
| 149 | + | } | |
| 150 | + | if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) { | |
| 151 | + | return true | |
| 152 | + | } | |
| 153 | + | contentType := strings.ToLower(resp.Header.Get("Content-Type")) | |
| 154 | + | return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml") | |
| 60 | 155 | } | |
| 61 | 156 | ||
| 62 | 157 | func newPipeline( | |
| @@ -394,7 +489,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI | |||
| 394 | 489 | ||
| 395 | 490 | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |
| 396 | 491 | if IsStreamingManifestURL(p.sourceURL) { | |
| 397 | - | return p.openManifestInputContext() | |
| 492 | + | // FFmpeg resolves manifest redirects and segment URLs internally, bypassing | |
| 493 | + | // the guarded Go HTTP client below. Keep untrusted transcode requests on | |
| 494 | + | // custom I/O until manifests can be fetched and rewritten through the same | |
| 495 | + | // SSRF protections. | |
| 496 | + | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |
| 398 | 497 | } | |
| 399 | 498 | return p.openCustomIOInputContext(ctx) | |
| 400 | 499 | } | |
| @@ -417,6 +516,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||
| 417 | 516 | _ = resp.Body.Close() | |
| 418 | 517 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |
| 419 | 518 | } | |
| 519 | + | if isStreamingManifestResponse(resp) { | |
| 520 | + | _ = resp.Body.Close() | |
| 521 | + | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |
| 522 | + | } | |
| 420 | 523 | ||
| 421 | 524 | body := resp.Body | |
| 422 | 525 | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |
M internal/infrastructure/webkit/scheme_handler.go +56 -17
| @@ -1,16 +1,19 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "embed" | |
| 6 | 7 | "fmt" | |
| 8 | + | "io" | |
| 7 | 9 | "io/fs" | |
| 8 | 10 | "net/http" | |
| 9 | 11 | "net/url" | |
| 10 | - | "path/filepath" | |
| 12 | + | "path" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "sync" | |
| 13 | 15 | ||
| 16 | + | "github.com/andybalholm/brotli" | |
| 14 | 17 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 15 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 16 | 19 | "github.com/bnema/puregotk/v4/gio" | |
| @@ -21,13 +24,15 @@ import ( | |||
| 21 | 24 | ||
| 22 | 25 | // Scheme path constants | |
| 23 | 26 | const ( | |
| 24 | - | HistoryPath = "history" | |
| 25 | - | FavoritesPath = "favorites" | |
| 26 | - | ConfigPath = "config" | |
| 27 | - | ErrorPath = "error" | |
| 28 | - | CrashPath = "crash" | |
| 29 | - | IndexHTML = "index.html" | |
| 30 | - | httpGET = "GET" | |
| 27 | + | HistoryPath = "history" | |
| 28 | + | FavoritesPath = "favorites" | |
| 29 | + | ConfigPath = "config" | |
| 30 | + | ErrorPath = "error" | |
| 31 | + | CrashPath = "crash" | |
| 32 | + | IndexHTML = "index.html" | |
| 33 | + | httpGET = "GET" | |
| 34 | + | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | |
| 35 | + | systemviewsAssetDir = "systemviews" | |
| 31 | 36 | ) | |
| 32 | 37 | ||
| 33 | 38 | // SchemeRequest represents a request to a custom URI scheme. | |
| @@ -76,7 +81,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||
| 76 | 81 | ||
| 77 | 82 | h := &DumbSchemeHandler{ | |
| 78 | 83 | handlers: make(map[string]PageHandler), | |
| 79 | - | assetDir: "systemviews", | |
| 84 | + | assetDir: systemviewsAssetDir, | |
| 80 | 85 | logger: log.With().Str("component", "scheme-handler").Logger(), | |
| 81 | 86 | } | |
| 82 | 87 | ||
| @@ -300,7 +305,11 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 300 | 305 | assetDir = h.assetDir | |
| 301 | 306 | } | |
| 302 | 307 | ||
| 303 | - | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |
| 308 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath) | |
| 309 | + | if !ok { | |
| 310 | + | return nil | |
| 311 | + | } | |
| 312 | + | ||
| 304 | 313 | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | |
| 305 | 314 | if err != nil { | |
| 306 | 315 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | |
| @@ -322,10 +331,40 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 322 | 331 | } | |
| 323 | 332 | } | |
| 324 | 333 | ||
| 334 | + | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |
| 335 | + | assetDir = strings.Trim(assetDir, "/") | |
| 336 | + | if assetDir != systemviewsAssetDir { | |
| 337 | + | return "", "", false | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | relPath = strings.TrimLeft(relPath, "/") | |
| 341 | + | if relPath == "" || strings.ContainsRune(relPath, '\x00') { | |
| 342 | + | return "", "", false | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | cleanRelPath = path.Clean(relPath) | |
| 346 | + | if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) { | |
| 347 | + | return "", "", false | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | fullPath = path.Join(assetDir, cleanRelPath) | |
| 351 | + | if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") { | |
| 352 | + | return "", "", false | |
| 353 | + | } | |
| 354 | + | return fullPath, cleanRelPath, true | |
| 355 | + | } | |
| 356 | + | ||
| 325 | 357 | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | |
| 326 | 358 | if strings.HasSuffix(relPath, ".wasm") { | |
| 327 | - | if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| 328 | - | return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, nil | |
| 359 | + | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| 360 | + | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |
| 361 | + | if err != nil { | |
| 362 | + | return nil, nil, err | |
| 363 | + | } | |
| 364 | + | if len(data) > maxSystemviewsWASMBytes { | |
| 365 | + | return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |
| 366 | + | } | |
| 367 | + | return data, nil, nil | |
| 329 | 368 | } | |
| 330 | 369 | } | |
| 331 | 370 | data, err := fs.ReadFile(assets, fullPath) | |
| @@ -341,10 +380,10 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 341 | 380 | assetDir string | |
| 342 | 381 | file string | |
| 343 | 382 | }{ | |
| 344 | - | HistoryPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 345 | - | FavoritesPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 346 | - | ConfigPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 347 | - | ErrorPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 383 | + | HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |
| 384 | + | FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |
| 385 | + | ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |
| 386 | + | ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |
| 348 | 387 | } | |
| 349 | 388 | ||
| 350 | 389 | if root, ok := rootByHost[u.Host]; ok { | |
| @@ -357,7 +396,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 357 | 396 | ||
| 358 | 397 | switch u.Opaque { | |
| 359 | 398 | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath: | |
| 360 | - | return "systemviews", IndexHTML, true | |
| 399 | + | return systemviewsAssetDir, IndexHTML, true | |
| 361 | 400 | default: | |
| 362 | 401 | return "", "", false | |
| 363 | 402 | } | |
M internal/infrastructure/webkit/scheme_handler_test.go +55 -0
| @@ -114,6 +114,7 @@ func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) { | |||
| 114 | 114 | ||
| 115 | 115 | resp := h.handleAsset(u) | |
| 116 | 116 | require.NotNil(t, resp) | |
| 117 | + | // Charset suffixes are acceptable; assert the MIME type contract only. | |
| 117 | 118 | assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0]) | |
| 118 | 119 | assert.Contains(t, string(resp.Data), ".sv-app") | |
| 119 | 120 | } | |
| @@ -213,3 +214,57 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||
| 213 | 214 | assert.Equal(t, "*", headers["Access-Control-Allow-Origin"]) | |
| 214 | 215 | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | |
| 215 | 216 | } | |
| 217 | + | ||
| 218 | + | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | |
| 219 | + | t.Parallel() | |
| 220 | + | ||
| 221 | + | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | require.GreaterOrEqual(t, len(data), 4) | |
| 224 | + | assert.Equal(t, "\x00asm", string(data[:4])) | |
| 225 | + | assert.NotContains(t, headers, "Content-Encoding") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | |
| 229 | + | h := NewDumbSchemeHandler(context.Background()) | |
| 230 | + | h.SetAssets(assets.WebUIAssets) | |
| 231 | + | ||
| 232 | + | for _, raw := range []string{ | |
| 233 | + | "dumb://history/../logo.svg", | |
| 234 | + | "dumb://history/nested/../../logo.svg", | |
| 235 | + | } { | |
| 236 | + | t.Run(raw, func(t *testing.T) { | |
| 237 | + | u, err := url.Parse(raw) | |
| 238 | + | require.NoError(t, err) | |
| 239 | + | assert.Nil(t, h.handleAsset(u)) | |
| 240 | + | }) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) { | |
| 245 | + | t.Parallel() | |
| 246 | + | ||
| 247 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css") | |
| 248 | + | require.True(t, ok) | |
| 249 | + | assert.Equal(t, "systemviews/systemviews.css", fullPath) | |
| 250 | + | assert.Equal(t, "systemviews.css", relPath) | |
| 251 | + | ||
| 252 | + | invalid := []struct { | |
| 253 | + | name string | |
| 254 | + | assetDir string | |
| 255 | + | relPath string | |
| 256 | + | }{ | |
| 257 | + | {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"}, | |
| 258 | + | {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"}, | |
| 259 | + | {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"}, | |
| 260 | + | {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"}, | |
| 261 | + | {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"}, | |
| 262 | + | } | |
| 263 | + | for _, tt := range invalid { | |
| 264 | + | t.Run(tt.name, func(t *testing.T) { | |
| 265 | + | t.Parallel() | |
| 266 | + | _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath) | |
| 267 | + | assert.False(t, ok) | |
| 268 | + | }) | |
| 269 | + | } | |
| 270 | + | } | |
M internal/infrastructure/webutil/mime.go +5 -1
| @@ -9,7 +9,11 @@ import ( | |||
| 9 | 9 | // GetMimeType determines the MIME type for a given file path. | |
| 10 | 10 | // It tries the standard library first, then falls back to common web asset types. | |
| 11 | 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 | 18 | mt := mime.TypeByExtension(ext) | |
| 15 | 19 | if mt != "" { | |
M internal/infrastructure/webutil/mime_test.go +23 -3
| @@ -2,10 +2,30 @@ package webutil | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | - | func TestGetMimeType_WASM(t *testing.T) { | |
| 5 | + | func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) { | |
| 6 | 6 | t.Parallel() | |
| 7 | 7 | ||
| 8 | - | if got := GetMimeType("systemviews.wasm"); got != "application/wasm" { | |
| 9 | - | t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm") | |
| 8 | + | tests := []struct { | |
| 9 | + | name string | |
| 10 | + | filename string | |
| 11 | + | want string | |
| 12 | + | }{ | |
| 13 | + | {name: "path input", filename: "assets/systemviews/systemviews.wasm", want: "application/wasm"}, | |
| 14 | + | {name: "uppercase extension", filename: "systemviews.WASM", want: "application/wasm"}, | |
| 15 | + | {name: "brotli compressed wasm", filename: "systemviews.wasm.br", want: "application/wasm"}, | |
| 16 | + | {name: "uppercase brotli compressed wasm", filename: "SYSTEMVIEWS.WASM.BR", want: "application/wasm"}, | |
| 17 | + | {name: "plain brotli", filename: "data.br", want: "text/plain"}, | |
| 18 | + | {name: "js brotli", filename: "app.js.br", want: "text/javascript; charset=utf-8"}, | |
| 19 | + | {name: "tar brotli", filename: "archive.tar.br", want: "application/x-tar"}, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | for _, tt := range tests { | |
| 23 | + | t.Run(tt.name, func(t *testing.T) { | |
| 24 | + | t.Parallel() | |
| 25 | + | ||
| 26 | + | if got := GetMimeType(tt.filename); got != tt.want { | |
| 27 | + | t.Fatalf("GetMimeType(%s) = %q, want %q", tt.filename, got, tt.want) | |
| 28 | + | } | |
| 29 | + | }) | |
| 10 | 30 | } | |
| 11 | 31 | } | |
M internal/ui/coordinator/workspace.go +19 -3
| @@ -231,13 +231,13 @@ func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, target | |||
| 231 | 231 | } | |
| 232 | 232 | if c.getActiveWS == nil { | |
| 233 | 233 | log.Warn().Str("url", targetURL).Msg("active workspace provider not configured") | |
| 234 | - | return nil | |
| 234 | + | return fmt.Errorf("system view toggle: active workspace provider not configured") | |
| 235 | 235 | } | |
| 236 | 236 | ||
| 237 | 237 | ws, wsView := c.getActiveWS() | |
| 238 | 238 | if ws == nil { | |
| 239 | 239 | log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle") | |
| 240 | - | return nil | |
| 240 | + | return fmt.Errorf("system view toggle: no active workspace") | |
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | 243 | activePane := ws.ActivePane() | |
| @@ -284,6 +284,10 @@ func (c *WorkspaceCoordinator) focusExistingPane( | |||
| 284 | 284 | ) { | |
| 285 | 285 | oldActivePaneID := ws.ActivePaneID | |
| 286 | 286 | ws.ActivePaneID = paneID | |
| 287 | + | paneNode := ws.FindPane(paneID) | |
| 288 | + | if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { | |
| 289 | + | setActiveStackIndexForChild(paneNode.Parent, paneNode) | |
| 290 | + | } | |
| 287 | 291 | ||
| 288 | 292 | if wsView != nil { | |
| 289 | 293 | wsView.CancelAllPendingHovers() | |
| @@ -296,7 +300,7 @@ func (c *WorkspaceCoordinator) focusExistingPane( | |||
| 296 | 300 | } else { | |
| 297 | 301 | wsView.FocusPane(paneID) | |
| 298 | 302 | } | |
| 299 | - | if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { | |
| 303 | + | if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { | |
| 300 | 304 | c.syncStackedViewActive(ctx, wsView, paneNode) | |
| 301 | 305 | } | |
| 302 | 306 | } | |
| @@ -304,6 +308,18 @@ func (c *WorkspaceCoordinator) focusExistingPane( | |||
| 304 | 308 | c.notifyStateChanged() | |
| 305 | 309 | } | |
| 306 | 310 | ||
| 311 | + | func setActiveStackIndexForChild(parent, child *entity.PaneNode) { | |
| 312 | + | if parent == nil || child == nil { | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | for i, candidate := range parent.Children { | |
| 316 | + | if candidate == child { | |
| 317 | + | parent.ActiveStackIndex = i | |
| 318 | + | return | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 307 | 323 | func (c *WorkspaceCoordinator) prepareSplit( | |
| 308 | 324 | ctx context.Context, | |
| 309 | 325 | direction usecase.SplitDirection, | |
M internal/ui/coordinator/workspace_systemview_test.go +33 -0
| @@ -53,6 +53,10 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin | |||
| 53 | 53 | ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID} | |
| 54 | 54 | ||
| 55 | 55 | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |
| 56 | + | PanesUC: usecase.NewManagePanesUseCase(func() string { | |
| 57 | + | t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane") | |
| 58 | + | return "unused" | |
| 59 | + | }), | |
| 56 | 60 | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |
| 57 | 61 | return ws, nil | |
| 58 | 62 | }, | |
| @@ -91,3 +95,32 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T | |||
| 91 | 95 | require.NotNil(t, active) | |
| 92 | 96 | assert.Equal(t, "https://example.com", active.Pane.URI) | |
| 93 | 97 | } | |
| 98 | + | ||
| 99 | + | func TestWorkspaceCoordinator_ToggleSystemViewRightUpdatesStackIndexForExistingPane(t *testing.T) { | |
| 100 | + | ctx := context.Background() | |
| 101 | + | first := testLeafNode("pane-1") | |
| 102 | + | first.Pane.URI = "https://example.com" | |
| 103 | + | second := testLeafNode("pane-2") | |
| 104 | + | second.Pane.URI = "dumb://history" | |
| 105 | + | stack := &entity.PaneNode{ | |
| 106 | + | ID: "stack-1", | |
| 107 | + | IsStacked: true, | |
| 108 | + | ActiveStackIndex: 0, | |
| 109 | + | Children: []*entity.PaneNode{first, second}, | |
| 110 | + | } | |
| 111 | + | first.Parent = stack | |
| 112 | + | second.Parent = stack | |
| 113 | + | ws := &entity.Workspace{ID: "ws-1", Root: stack, ActivePaneID: first.Pane.ID} | |
| 114 | + | ||
| 115 | + | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |
| 116 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |
| 117 | + | return ws, nil | |
| 118 | + | }, | |
| 119 | + | }) | |
| 120 | + | ||
| 121 | + | err := coord.ToggleSystemViewRight(ctx, "dumb://history") | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | ||
| 124 | + | assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID) | |
| 125 | + | assert.Equal(t, 1, stack.ActiveStackIndex) | |
| 126 | + | } | |
M internal/ui/input/global_shortcuts.go +24 -29
| @@ -104,21 +104,7 @@ func NewGlobalShortcutHandler( | |||
| 104 | 104 | Msg("registered global shortcut") | |
| 105 | 105 | ||
| 106 | 106 | if workspace != nil { | |
| 107 | - | // Map action names to action constants for global shortcuts | |
| 108 | - | actionMap := map[string]Action{ | |
| 109 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 110 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 111 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 112 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 113 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 114 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 115 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 116 | - | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 117 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |
| 118 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |
| 119 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| 120 | - | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 121 | - | } | |
| 107 | + | actionMap := globalShortcutActionMap() | |
| 122 | 108 | ||
| 123 | 109 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | |
| 124 | 110 | action, ok := actionMap[actionName] | |
| @@ -253,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 253 | 239 | return true | |
| 254 | 240 | } | |
| 255 | 241 | ||
| 242 | + | // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions. | |
| 243 | + | func globalShortcutActionMap() map[string]Action { | |
| 244 | + | return map[string]Action{ | |
| 245 | + | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 246 | + | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 247 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 248 | + | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 249 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 250 | + | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 251 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 252 | + | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 253 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 254 | + | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 255 | + | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 256 | + | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 257 | + | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |
| 258 | + | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |
| 259 | + | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| 260 | + | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 256 | 264 | // ReloadShortcuts rebuilds global shortcuts from a new config. | |
| 257 | 265 | // It replaces the GTK shortcut controller to ensure stale shortcuts | |
| 258 | 266 | // are removed. | |
| @@ -291,20 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 291 | 299 | ||
| 292 | 300 | // Re-register config-driven shortcuts | |
| 293 | 301 | if workspace != nil { | |
| 294 | - | actionMap := map[string]Action{ | |
| 295 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 296 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 297 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 298 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 299 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 300 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 301 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 302 | - | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 303 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |
| 304 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |
| 305 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| 306 | - | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 307 | - | } | |
| 302 | + | actionMap := globalShortcutActionMap() | |
| 308 | 303 | ||
| 309 | 304 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | |
| 310 | 305 | action, ok := actionMap[actionName] | |
M internal/ui/input/shortcuts.go +19 -28
| @@ -362,35 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||
| 362 | 362 | // r = rename tab | |
| 363 | 363 | // This follows Zellij-style modal keyboard interface. | |
| 364 | 364 | // | |
| 365 | - | // However, these standard browser shortcuts ARE global: | |
| 366 | - | // Map action names to action constants | |
| 367 | - | actionMap := map[string]Action{ | |
| 368 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 369 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 370 | - | "close_pane": ActionClosePane, | |
| 371 | - | "close-pane": ActionClosePane, | |
| 372 | - | "next_tab": ActionNextTab, | |
| 373 | - | "next-tab": ActionNextTab, | |
| 374 | - | "previous_tab": ActionPreviousTab, | |
| 375 | - | "previous-tab": ActionPreviousTab, | |
| 376 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 377 | - | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 378 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 379 | - | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 380 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 381 | - | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 382 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 383 | - | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 384 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 385 | - | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 386 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |
| 387 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |
| 388 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| 389 | - | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 390 | - | } | |
| 391 | - | ||
| 365 | + | // However, these standard browser shortcuts ARE global. | |
| 392 | 366 | for actionName, actionBinding := range cfg.Shortcuts.Actions { | |
| 393 | - | action, ok := actionMap[actionName] | |
| 367 | + | action, ok := configActionToAction[actionName] | |
| 394 | 368 | if !ok { | |
| 395 | 369 | continue | |
| 396 | 370 | } | |
| @@ -599,25 +573,42 @@ var configActionToAction = map[string]Action{ | |||
| 599 | 573 | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 600 | 574 | ||
| 601 | 575 | // Tab actions | |
| 576 | + | "new_tab": ActionNewTab, | |
| 602 | 577 | "new-tab": ActionNewTab, | |
| 578 | + | "close_tab": ActionCloseTab, | |
| 603 | 579 | "close-tab": ActionCloseTab, | |
| 580 | + | "next_tab": ActionNextTab, | |
| 604 | 581 | "next-tab": ActionNextTab, | |
| 582 | + | "previous_tab": ActionPreviousTab, | |
| 605 | 583 | "previous-tab": ActionPreviousTab, | |
| 584 | + | "rename_tab": ActionRenameTab, | |
| 606 | 585 | "rename-tab": ActionRenameTab, | |
| 607 | 586 | ||
| 608 | 587 | // Pane actions | |
| 588 | + | "split_right": ActionSplitRight, | |
| 609 | 589 | "split-right": ActionSplitRight, | |
| 590 | + | "split_left": ActionSplitLeft, | |
| 610 | 591 | "split-left": ActionSplitLeft, | |
| 592 | + | "split_up": ActionSplitUp, | |
| 611 | 593 | "split-up": ActionSplitUp, | |
| 594 | + | "split_down": ActionSplitDown, | |
| 612 | 595 | "split-down": ActionSplitDown, | |
| 596 | + | "close_pane": ActionClosePane, | |
| 613 | 597 | "close-pane": ActionClosePane, | |
| 598 | + | "stack_pane": ActionStackPane, | |
| 614 | 599 | "stack-pane": ActionStackPane, | |
| 600 | + | "move_pane_to_tab": ActionMovePaneToTab, | |
| 615 | 601 | "move-pane-to-tab": ActionMovePaneToTab, | |
| 602 | + | "move_pane_to_next_tab": ActionMovePaneToNextTab, | |
| 616 | 603 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | |
| 617 | 604 | ||
| 605 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 618 | 606 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 607 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 619 | 608 | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 609 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 620 | 610 | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 611 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 621 | 612 | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 622 | 613 | ||
| 623 | 614 | // Focus navigation | |
M internal/ui/input/shortcuts_test.go +40 -0
| @@ -402,9 +402,49 @@ func TestMapConfigAction_ToggleSystemViews(t *testing.T) { | |||
| 402 | 402 | } | |
| 403 | 403 | } | |
| 404 | 404 | ||
| 405 | + | func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) { | |
| 406 | + | tests := []struct { | |
| 407 | + | name string | |
| 408 | + | want Action | |
| 409 | + | }{ | |
| 410 | + | {name: "toggle-history-systemview", want: ActionToggleHistorySystemView}, | |
| 411 | + | {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView}, | |
| 412 | + | {name: "toggle-config-systemview", want: ActionToggleConfigSystemView}, | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | for _, tt := range tests { | |
| 416 | + | t.Run(tt.name, func(t *testing.T) { | |
| 417 | + | if got := mapConfigAction(tt.name); got != tt.want { | |
| 418 | + | t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want) | |
| 419 | + | } | |
| 420 | + | }) | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | ||
| 405 | 424 | func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) { | |
| 406 | 425 | action := mapConfigAction("toggle-floating-pane") | |
| 407 | 426 | if action != ActionToggleFloatingPane { | |
| 408 | 427 | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) | |
| 409 | 428 | } | |
| 410 | 429 | } | |
| 430 | + | ||
| 431 | + | func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) { | |
| 432 | + | actions := globalShortcutActionMap() | |
| 433 | + | tests := []struct { | |
| 434 | + | name string | |
| 435 | + | want Action | |
| 436 | + | }{ | |
| 437 | + | {name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft}, | |
| 438 | + | {name: "consume-or-expel-right", want: ActionConsumeOrExpelRight}, | |
| 439 | + | {name: "consume-or-expel-up", want: ActionConsumeOrExpelUp}, | |
| 440 | + | {name: "consume-or-expel-down", want: ActionConsumeOrExpelDown}, | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | for _, tt := range tests { | |
| 444 | + | t.Run(tt.name, func(t *testing.T) { | |
| 445 | + | if got := actions[tt.name]; got != tt.want { | |
| 446 | + | t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want) | |
| 447 | + | } | |
| 448 | + | }) | |
| 449 | + | } | |
| 450 | + | } | |
A internal/ui/systemviews/action_log.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 3 | + | package systemviews | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func logActionMountError(ctx context.Context, mountErr, actionErr error) { | |
| 12 | + | if mountErr == nil && actionErr == nil { | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | logging.FromContext(ctx). | |
| 16 | + | Error(). | |
| 17 | + | Err(mountErr). | |
| 18 | + | Str("action_error", errorString(actionErr)). | |
| 19 | + | Msg("failed to mount systemview action error") | |
| 20 | + | } | |
A internal/ui/systemviews/action_log_common.go +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | func errorString(err error) string { | |
| 4 | + | if err == nil { | |
| 5 | + | return "<nil>" | |
| 6 | + | } | |
| 7 | + | return err.Error() | |
| 8 | + | } | |
A internal/ui/systemviews/action_log_js.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | //go:build js && wasm | |
| 2 | + | ||
| 3 | + | package systemviews | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "syscall/js" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func logActionMountError(_ context.Context, mountErr, actionErr error) { | |
| 11 | + | if mountErr == nil && actionErr == nil { | |
| 12 | + | return | |
| 13 | + | } | |
| 14 | + | console := js.Global().Get("console") | |
| 15 | + | if !console.Truthy() || !console.Get("error").Truthy() { | |
| 16 | + | return | |
| 17 | + | } | |
| 18 | + | console.Call("error", "failed to mount systemview action error", errorString(mountErr), errorString(actionErr)) | |
| 19 | + | } | |
M internal/ui/systemviews/app.go +219 -51
| @@ -20,6 +20,8 @@ type Dependencies struct { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | type App struct { | |
| 23 | + | // mu protects rendered route state; actionMu protects worker channels. | |
| 24 | + | // The two locks must not be held at the same time. | |
| 23 | 25 | mu sync.Mutex | |
| 24 | 26 | actionMu sync.Mutex | |
| 25 | 27 | deps Dependencies | |
| @@ -46,12 +48,33 @@ type App struct { | |||
| 46 | 48 | configError string | |
| 47 | 49 | renderedHTML string | |
| 48 | 50 | actionQueue chan DOMAction | |
| 51 | + | actionErrorQueue chan error | |
| 49 | 52 | actionCtx context.Context | |
| 50 | 53 | actionCancel context.CancelFunc | |
| 54 | + | actionClosed bool | |
| 55 | + | actionWG sync.WaitGroup | |
| 51 | 56 | } | |
| 52 | 57 | ||
| 53 | 58 | const historyTimelineLimit = 25 | |
| 54 | 59 | ||
| 60 | + | func (a *App) lockAction() { | |
| 61 | + | // Lock order: actionMu is never acquired while App.mu is held. | |
| 62 | + | a.actionMu.Lock() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (a *App) unlockAction() { | |
| 66 | + | a.actionMu.Unlock() | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (a *App) lockState() { | |
| 70 | + | // Lock order: App.mu is never held while acquiring actionMu. | |
| 71 | + | a.mu.Lock() | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (a *App) unlockState() { | |
| 75 | + | a.mu.Unlock() | |
| 76 | + | } | |
| 77 | + | ||
| 55 | 78 | func NewApp(deps Dependencies) *App { | |
| 56 | 79 | return &App{deps: deps, currentRoute: RouteUnknown} | |
| 57 | 80 | } | |
| @@ -65,16 +88,16 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||
| 65 | 88 | return errors.New("app is nil") | |
| 66 | 89 | } | |
| 67 | 90 | if ctx == nil { | |
| 68 | - | ctx = context.Background() | |
| 91 | + | return errors.New("context is nil") | |
| 69 | 92 | } | |
| 70 | 93 | ||
| 71 | 94 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 72 | - | if err := a.LoadInitial(ctx); err != nil { | |
| 73 | - | return err | |
| 74 | - | } | |
| 75 | 95 | if a.deps.DOM == nil { | |
| 76 | 96 | return errors.New("DOM not configured") | |
| 77 | 97 | } | |
| 98 | + | if err := a.LoadInitial(ctx); err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 78 | 101 | ||
| 79 | 102 | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { | |
| 80 | 103 | return err | |
| @@ -86,43 +109,114 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||
| 86 | 109 | } | |
| 87 | 110 | ||
| 88 | 111 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | |
| 89 | - | a.actionMu.Lock() | |
| 112 | + | var ( | |
| 113 | + | created bool | |
| 114 | + | workerCtx context.Context | |
| 115 | + | cancel context.CancelFunc | |
| 116 | + | queue chan DOMAction | |
| 117 | + | errorQueue chan error | |
| 118 | + | ) | |
| 119 | + | ||
| 120 | + | a.lockAction() | |
| 121 | + | if a.actionClosed { | |
| 122 | + | a.unlockAction() | |
| 123 | + | return errors.New("systemview app is closed") | |
| 124 | + | } | |
| 90 | 125 | if a.actionQueue == nil { | |
| 91 | - | workerCtx, cancel := context.WithCancel(ctx) | |
| 92 | - | queue := make(chan DOMAction, 64) | |
| 126 | + | workerCtx, cancel = context.WithCancel(ctx) | |
| 127 | + | queue = make(chan DOMAction, 64) | |
| 128 | + | errorQueue = make(chan error, 1) | |
| 93 | 129 | a.actionQueue = queue | |
| 130 | + | a.actionErrorQueue = errorQueue | |
| 94 | 131 | a.actionCtx = workerCtx | |
| 95 | 132 | a.actionCancel = cancel | |
| 96 | - | go a.runActionWorker(workerCtx, queue) | |
| 133 | + | created = true | |
| 97 | 134 | } | |
| 98 | - | a.actionMu.Unlock() | |
| 99 | - | return binder.BindActions(func(action DOMAction) { | |
| 135 | + | a.unlockAction() | |
| 136 | + | ||
| 137 | + | if err := binder.BindActions(func(action DOMAction) { | |
| 100 | 138 | if !a.enqueueDOMAction(action) && a.actionWorkerActive() { | |
| 101 | - | go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action)) | |
| 139 | + | a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action)) | |
| 140 | + | } | |
| 141 | + | }); err != nil { | |
| 142 | + | if created { | |
| 143 | + | a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue) | |
| 144 | + | } | |
| 145 | + | return err | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if created && !a.startActionWorkers(workerCtx, queue, errorQueue) { | |
| 149 | + | a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue) | |
| 150 | + | a.releaseDOMBindings() | |
| 151 | + | return errors.New("systemview action worker unavailable") | |
| 152 | + | } | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool { | |
| 157 | + | if a == nil || ctx == nil || queue == nil || errorQueue == nil { | |
| 158 | + | return false | |
| 159 | + | } | |
| 160 | + | a.lockAction() | |
| 161 | + | ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil | |
| 162 | + | if ready { | |
| 163 | + | a.actionWG.Add(2) | |
| 164 | + | } | |
| 165 | + | a.unlockAction() | |
| 166 | + | if !ready { | |
| 167 | + | return false | |
| 168 | + | } | |
| 169 | + | go func() { | |
| 170 | + | defer a.actionWG.Done() | |
| 171 | + | a.runActionWorker(ctx, queue) | |
| 172 | + | }() | |
| 173 | + | go func() { | |
| 174 | + | defer a.actionWG.Done() | |
| 175 | + | a.runActionErrorWorker(ctx, errorQueue) | |
| 176 | + | }() | |
| 177 | + | return true | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) { | |
| 181 | + | if a == nil { | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | a.lockAction() | |
| 185 | + | if a.actionCtx == ctx && a.actionQueue == queue { | |
| 186 | + | a.actionCancel = nil | |
| 187 | + | a.actionCtx = nil | |
| 188 | + | a.actionQueue = nil | |
| 189 | + | a.actionErrorQueue = nil | |
| 190 | + | if cancel != nil { | |
| 191 | + | cancel() | |
| 102 | 192 | } | |
| 103 | - | }) | |
| 193 | + | close(queue) | |
| 194 | + | close(errorQueue) | |
| 195 | + | } | |
| 196 | + | a.unlockAction() | |
| 104 | 197 | } | |
| 105 | 198 | ||
| 106 | 199 | func (a *App) actionWorkerActive() bool { | |
| 107 | 200 | if a == nil { | |
| 108 | 201 | return false | |
| 109 | 202 | } | |
| 110 | - | a.actionMu.Lock() | |
| 203 | + | a.lockAction() | |
| 204 | + | closed := a.actionClosed | |
| 111 | 205 | queue := a.actionQueue | |
| 112 | 206 | ctx := a.actionCtx | |
| 113 | - | a.actionMu.Unlock() | |
| 114 | - | return queue != nil && ctx != nil && ctx.Err() == nil | |
| 207 | + | a.unlockAction() | |
| 208 | + | return !closed && queue != nil && ctx != nil && ctx.Err() == nil | |
| 115 | 209 | } | |
| 116 | 210 | ||
| 117 | 211 | func (a *App) enqueueDOMAction(action DOMAction) bool { | |
| 118 | 212 | if a == nil { | |
| 119 | 213 | return false | |
| 120 | 214 | } | |
| 121 | - | a.actionMu.Lock() | |
| 215 | + | a.lockAction() | |
| 216 | + | defer a.unlockAction() | |
| 122 | 217 | queue := a.actionQueue | |
| 123 | 218 | ctx := a.actionCtx | |
| 124 | - | a.actionMu.Unlock() | |
| 125 | - | if queue == nil || ctx == nil { | |
| 219 | + | if a.actionClosed || queue == nil || ctx == nil { | |
| 126 | 220 | return false | |
| 127 | 221 | } | |
| 128 | 222 | select { | |
| @@ -135,45 +229,100 @@ func (a *App) enqueueDOMAction(action DOMAction) bool { | |||
| 135 | 229 | } | |
| 136 | 230 | } | |
| 137 | 231 | ||
| 232 | + | func (a *App) enqueueActionError(err error) { | |
| 233 | + | if a == nil || err == nil { | |
| 234 | + | return | |
| 235 | + | } | |
| 236 | + | a.lockAction() | |
| 237 | + | defer a.unlockAction() | |
| 238 | + | queue := a.actionErrorQueue | |
| 239 | + | ctx := a.actionCtx | |
| 240 | + | if a.actionClosed || queue == nil || ctx == nil { | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | select { | |
| 244 | + | case <-ctx.Done(): | |
| 245 | + | return | |
| 246 | + | case queue <- err: | |
| 247 | + | default: | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 138 | 251 | func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) { | |
| 139 | 252 | for { | |
| 140 | 253 | select { | |
| 141 | 254 | case <-ctx.Done(): | |
| 142 | 255 | return | |
| 143 | 256 | case action, ok := <-queue: | |
| 144 | - | if !ok { | |
| 257 | + | if !ok || ctx.Err() != nil { | |
| 145 | 258 | return | |
| 146 | 259 | } | |
| 147 | 260 | if err := a.HandleDOMAction(ctx, action); err != nil { | |
| 148 | - | a.surfaceActionError(err) | |
| 261 | + | a.enqueueActionError(err) | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) { | |
| 268 | + | for { | |
| 269 | + | select { | |
| 270 | + | case <-ctx.Done(): | |
| 271 | + | return | |
| 272 | + | case err, ok := <-queue: | |
| 273 | + | if !ok || ctx.Err() != nil { | |
| 274 | + | return | |
| 149 | 275 | } | |
| 276 | + | a.surfaceActionError(ctx, err) | |
| 150 | 277 | } | |
| 151 | 278 | } | |
| 152 | 279 | } | |
| 153 | 280 | ||
| 154 | - | func (a *App) surfaceActionError(err error) { | |
| 281 | + | func (a *App) surfaceActionError(ctx context.Context, err error) { | |
| 155 | 282 | if a == nil || err == nil { | |
| 156 | 283 | return | |
| 157 | 284 | } | |
| 158 | - | a.mu.Lock() | |
| 285 | + | a.lockState() | |
| 159 | 286 | a.renderRouteError(err) | |
| 160 | - | a.mu.Unlock() | |
| 161 | - | _ = a.mountRenderedHTML() | |
| 287 | + | html := a.renderedHTML | |
| 288 | + | a.unlockState() | |
| 289 | + | if mountErr := a.mountHTML(html); mountErr != nil { | |
| 290 | + | logActionMountError(ctx, mountErr, err) | |
| 291 | + | } | |
| 162 | 292 | } | |
| 163 | 293 | ||
| 164 | 294 | func (a *App) Close() { | |
| 165 | 295 | if a == nil { | |
| 166 | 296 | return | |
| 167 | 297 | } | |
| 168 | - | a.actionMu.Lock() | |
| 298 | + | a.lockAction() | |
| 169 | 299 | cancel := a.actionCancel | |
| 300 | + | actionQueue := a.actionQueue | |
| 301 | + | actionErrorQueue := a.actionErrorQueue | |
| 302 | + | a.actionClosed = true | |
| 170 | 303 | a.actionCancel = nil | |
| 171 | 304 | a.actionCtx = nil | |
| 172 | 305 | a.actionQueue = nil | |
| 173 | - | a.actionMu.Unlock() | |
| 306 | + | a.actionErrorQueue = nil | |
| 307 | + | a.unlockAction() | |
| 308 | + | ||
| 174 | 309 | if cancel != nil { | |
| 175 | 310 | cancel() | |
| 176 | 311 | } | |
| 312 | + | a.actionWG.Wait() | |
| 313 | + | if actionQueue != nil { | |
| 314 | + | close(actionQueue) | |
| 315 | + | } | |
| 316 | + | if actionErrorQueue != nil { | |
| 317 | + | close(actionErrorQueue) | |
| 318 | + | } | |
| 319 | + | a.releaseDOMBindings() | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func (a *App) releaseDOMBindings() { | |
| 323 | + | if a == nil { | |
| 324 | + | return | |
| 325 | + | } | |
| 177 | 326 | if releaser, ok := a.deps.DOM.(interface{ Release() }); ok { | |
| 178 | 327 | releaser.Release() | |
| 179 | 328 | } | |
| @@ -200,6 +349,7 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 200 | 349 | a.resetRouteState() | |
| 201 | 350 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 202 | 351 | route: a.currentRoute, | |
| 352 | + | title: routeDocumentTitle(a.currentRoute), | |
| 203 | 353 | subtitle: string(a.currentRoute), | |
| 204 | 354 | body: placeholderHTML(a.currentRoute), | |
| 205 | 355 | }, a.shellTheme) | |
| @@ -219,11 +369,25 @@ func (a *App) renderRouteError(err error) { | |||
| 219 | 369 | } | |
| 220 | 370 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 221 | 371 | route: a.currentRoute, | |
| 372 | + | title: routeDocumentTitle(a.currentRoute) + " — Error", | |
| 222 | 373 | subtitle: routeSubtitle(a.currentRoute), | |
| 223 | 374 | body: errorStateHTML(message), | |
| 224 | 375 | }, a.shellTheme) | |
| 225 | 376 | } | |
| 226 | 377 | ||
| 378 | + | func routeDocumentTitle(route Route) string { | |
| 379 | + | switch route { | |
| 380 | + | case RouteHistory: | |
| 381 | + | return "History" | |
| 382 | + | case RouteFavorites: | |
| 383 | + | return "Favorites" | |
| 384 | + | case RouteConfig: | |
| 385 | + | return "Config" | |
| 386 | + | default: | |
| 387 | + | return "Dumber System View" | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 227 | 391 | func routeSubtitle(route Route) string { | |
| 228 | 392 | switch route { | |
| 229 | 393 | case RouteHistory: | |
| @@ -242,6 +406,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 242 | 406 | a.resetRouteState() | |
| 243 | 407 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 244 | 408 | route: a.currentRoute, | |
| 409 | + | title: routeDocumentTitle(RouteHistory), | |
| 245 | 410 | subtitle: "Recent visits", | |
| 246 | 411 | body: placeholderHTML(a.currentRoute), | |
| 247 | 412 | }, a.shellTheme) | |
| @@ -266,20 +431,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 266 | 431 | a.historyEntries = entries | |
| 267 | 432 | a.historyAnalytics = analytics | |
| 268 | 433 | a.historyDomainStats = domains | |
| 434 | + | data := historyRenderData{ | |
| 435 | + | Entries: entries, | |
| 436 | + | Analytics: analytics, | |
| 437 | + | Domains: domains, | |
| 438 | + | Query: a.historyQuery, | |
| 439 | + | DomainFilter: a.historyDomainFilter, | |
| 440 | + | Offset: a.historyOffset, | |
| 441 | + | Limit: historyTimelineLimit, | |
| 442 | + | Notice: a.historyNotice, | |
| 443 | + | Error: a.historyError, | |
| 444 | + | } | |
| 269 | 445 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 270 | 446 | route: RouteHistory, | |
| 447 | + | title: historyDocumentTitle(data), | |
| 271 | 448 | subtitle: "Recent visits", | |
| 272 | - | body: historyHTML(historyRenderData{ | |
| 273 | - | Entries: entries, | |
| 274 | - | Analytics: analytics, | |
| 275 | - | Domains: domains, | |
| 276 | - | Query: a.historyQuery, | |
| 277 | - | DomainFilter: a.historyDomainFilter, | |
| 278 | - | Offset: a.historyOffset, | |
| 279 | - | Limit: historyTimelineLimit, | |
| 280 | - | Notice: a.historyNotice, | |
| 281 | - | Error: a.historyError, | |
| 282 | - | }), | |
| 449 | + | body: historyHTML(data), | |
| 283 | 450 | }, a.shellTheme) | |
| 284 | 451 | return nil | |
| 285 | 452 | } | |
| @@ -288,11 +455,7 @@ func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, e | |||
| 288 | 455 | query := strings.TrimSpace(a.historyQuery) | |
| 289 | 456 | domain := strings.TrimSpace(a.historyDomainFilter) | |
| 290 | 457 | if query != "" { | |
| 291 | - | entries, err := a.deps.History.Search(ctx, query, historyTimelineLimit) | |
| 292 | - | if err != nil || domain == "" { | |
| 293 | - | return entries, err | |
| 294 | - | } | |
| 295 | - | return filterHistoryEntriesByDomain(entries, domain), nil | |
| 458 | + | return a.deps.History.Search(ctx, query, historyTimelineLimit) | |
| 296 | 459 | } | |
| 297 | 460 | if domain != "" { | |
| 298 | 461 | return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset) | |
| @@ -305,6 +468,7 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||
| 305 | 468 | a.resetRouteState() | |
| 306 | 469 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 307 | 470 | route: a.currentRoute, | |
| 471 | + | title: routeDocumentTitle(RouteFavorites), | |
| 308 | 472 | subtitle: "Saved bookmarks", | |
| 309 | 473 | body: placeholderHTML(a.currentRoute), | |
| 310 | 474 | }, a.shellTheme) | |
| @@ -329,18 +493,20 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||
| 329 | 493 | a.favorites = favorites | |
| 330 | 494 | a.folders = folders | |
| 331 | 495 | a.tags = tags | |
| 496 | + | data := favoritesRenderData{ | |
| 497 | + | Favorites: favorites, | |
| 498 | + | Folders: folders, | |
| 499 | + | Tags: tags, | |
| 500 | + | FolderFilter: a.favoriteFolderFilter, | |
| 501 | + | TagFilter: a.favoriteTagFilter, | |
| 502 | + | Notice: a.favoritesNotice, | |
| 503 | + | Error: a.favoritesError, | |
| 504 | + | } | |
| 332 | 505 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 333 | 506 | route: RouteFavorites, | |
| 507 | + | title: favoritesDocumentTitle(data), | |
| 334 | 508 | subtitle: "Saved bookmarks", | |
| 335 | - | body: favoritesHTML(favoritesRenderData{ | |
| 336 | - | Favorites: favorites, | |
| 337 | - | Folders: folders, | |
| 338 | - | Tags: tags, | |
| 339 | - | FolderFilter: a.favoriteFolderFilter, | |
| 340 | - | TagFilter: a.favoriteTagFilter, | |
| 341 | - | Notice: a.favoritesNotice, | |
| 342 | - | Error: a.favoritesError, | |
| 343 | - | }), | |
| 509 | + | body: favoritesHTML(data), | |
| 344 | 510 | }, a.shellTheme) | |
| 345 | 511 | return nil | |
| 346 | 512 | } | |
| @@ -350,6 +516,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||
| 350 | 516 | a.resetRouteState() | |
| 351 | 517 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 352 | 518 | route: a.currentRoute, | |
| 519 | + | title: routeDocumentTitle(RouteConfig), | |
| 353 | 520 | subtitle: "Browser settings", | |
| 354 | 521 | body: placeholderHTML(a.currentRoute), | |
| 355 | 522 | }, a.shellTheme) | |
| @@ -373,6 +540,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||
| 373 | 540 | a.keybindings = keybindings | |
| 374 | 541 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 375 | 542 | route: RouteConfig, | |
| 543 | + | title: "Config — Dumber", | |
| 376 | 544 | subtitle: "Browser settings", | |
| 377 | 545 | body: configHTML(configRenderData{ | |
| 378 | 546 | Config: config, | |
M internal/ui/systemviews/app_test.go +224 -5
| @@ -19,6 +19,11 @@ func TestParseRoute(t *testing.T) { | |||
| 19 | 19 | uri string | |
| 20 | 20 | want Route | |
| 21 | 21 | }{ | |
| 22 | + | {name: "empty", uri: "", want: RouteUnknown}, | |
| 23 | + | {name: "scheme only", uri: "://", want: RouteUnknown}, | |
| 24 | + | {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown}, | |
| 25 | + | {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown}, | |
| 26 | + | {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown}, | |
| 22 | 27 | {name: "history host", uri: "dumb://history", want: RouteHistory}, | |
| 23 | 28 | {name: "history opaque", uri: "dumb:history", want: RouteHistory}, | |
| 24 | 29 | {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites}, | |
| @@ -81,6 +86,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 81 | 86 | assert.NotContains(t, app.renderedHTML, "<body") | |
| 82 | 87 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 83 | 88 | assert.Contains(t, app.renderedHTML, `data-route="history"`) | |
| 89 | + | assert.Contains(t, app.renderedHTML, `data-page-title="History — 1 entry"`) | |
| 84 | 90 | assert.Contains(t, app.renderedHTML, `sv-section`) | |
| 85 | 91 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |
| 86 | 92 | } | |
| @@ -133,11 +139,13 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 133 | 139 | assert.Contains(t, dom.html, "Search result") | |
| 134 | 140 | assert.Contains(t, dom.html, "Query: example") | |
| 135 | 141 | ||
| 142 | + | app.historyOffset = historyTimelineLimit | |
| 136 | 143 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 137 | 144 | Action: historyActionDeleteEntry, | |
| 138 | 145 | Data: map[string]string{"id": "42"}, | |
| 139 | 146 | })) | |
| 140 | 147 | assert.Equal(t, int64(42), history.deletedEntryID) | |
| 148 | + | assert.Equal(t, 0, app.historyOffset) | |
| 141 | 149 | assert.Contains(t, dom.html, "Deleted history entry") | |
| 142 | 150 | ||
| 143 | 151 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -154,6 +162,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 154 | 162 | })) | |
| 155 | 163 | assert.True(t, history.domainCalled) | |
| 156 | 164 | assert.Equal(t, "example.com", history.domain) | |
| 165 | + | assert.Empty(t, app.historyQuery) | |
| 157 | 166 | ||
| 158 | 167 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 159 | 168 | Action: historyActionDeleteDomain, | |
| @@ -181,6 +190,37 @@ func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) { | |||
| 181 | 190 | }) | |
| 182 | 191 | } | |
| 183 | 192 | ||
| 193 | + | func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) { | |
| 194 | + | bindErr := errors.New("bind failed") | |
| 195 | + | dom := &failingActionDOM{bindErr: bindErr} | |
| 196 | + | app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"}) | |
| 197 | + | ||
| 198 | + | err := app.RunWithContext(context.Background()) | |
| 199 | + | require.ErrorIs(t, err, bindErr) | |
| 200 | + | assert.Nil(t, app.actionQueue) | |
| 201 | + | assert.Nil(t, app.actionErrorQueue) | |
| 202 | + | assert.Nil(t, app.actionCtx) | |
| 203 | + | assert.False(t, app.actionWorkerActive()) | |
| 204 | + | require.NotPanics(t, app.Close) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) { | |
| 208 | + | dom := &fakeActionDOM{} | |
| 209 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}} | |
| 210 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 211 | + | ||
| 212 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 213 | + | cancel() | |
| 214 | + | err := app.RunWithContext(ctx) | |
| 215 | + | ||
| 216 | + | require.ErrorContains(t, err, "systemview action worker unavailable") | |
| 217 | + | assert.True(t, dom.released) | |
| 218 | + | assert.Nil(t, app.actionQueue) | |
| 219 | + | assert.Nil(t, app.actionErrorQueue) | |
| 220 | + | assert.Nil(t, app.actionCtx) | |
| 221 | + | assert.False(t, app.actionWorkerActive()) | |
| 222 | + | } | |
| 223 | + | ||
| 184 | 224 | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | |
| 185 | 225 | t.Parallel() | |
| 186 | 226 | ||
| @@ -278,6 +318,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||
| 278 | 318 | assert.NotContains(t, app.renderedHTML, "<body") | |
| 279 | 319 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 280 | 320 | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | |
| 321 | + | assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`) | |
| 281 | 322 | assert.Contains(t, app.renderedHTML, `sv-section`) | |
| 282 | 323 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 283 | 324 | } | |
| @@ -306,6 +347,8 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | |||
| 306 | 347 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | |
| 307 | 348 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | |
| 308 | 349 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | |
| 350 | + | assert.Contains(t, app.renderedHTML, `name="tags"`) | |
| 351 | + | assert.Contains(t, app.renderedHTML, `value="7"`) | |
| 309 | 352 | assert.Contains(t, app.renderedHTML, "Read Later") | |
| 310 | 353 | assert.Contains(t, app.renderedHTML, "Go") | |
| 311 | 354 | assert.Contains(t, app.renderedHTML, "Shortcut 3") | |
| @@ -322,11 +365,12 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 322 | 365 | ||
| 323 | 366 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 324 | 367 | Action: favoriteActionCreate, | |
| 325 | - | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5"}, | |
| 368 | + | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"}, | |
| 326 | 369 | })) | |
| 327 | 370 | require.NotNil(t, favorites.createdFavorite.FolderID) | |
| 328 | 371 | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | |
| 329 | 372 | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | |
| 373 | + | assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags) | |
| 330 | 374 | assert.Contains(t, dom.html, "Added favorite New") | |
| 331 | 375 | ||
| 332 | 376 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -362,6 +406,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 362 | 406 | assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter) | |
| 363 | 407 | assert.Nil(t, app.favoriteFolderFilter) | |
| 364 | 408 | ||
| 409 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 410 | + | Action: folderActionCreate, | |
| 411 | + | Data: map[string]string{"name": "Reading", "icon": "📚"}, | |
| 412 | + | })) | |
| 413 | + | assert.Equal(t, "Reading", favorites.createdFolder) | |
| 414 | + | assert.Equal(t, "📚", favorites.createdFolderIcon) | |
| 415 | + | assert.Contains(t, dom.html, "Created folder 📚 Reading") | |
| 416 | + | ||
| 365 | 417 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 366 | 418 | Action: favoriteActionDelete, | |
| 367 | 419 | Data: map[string]string{"id": "42"}, | |
| @@ -492,6 +544,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 492 | 544 | assert.NotContains(t, app.renderedHTML, "<body") | |
| 493 | 545 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 494 | 546 | assert.Contains(t, app.renderedHTML, `data-route="config"`) | |
| 547 | + | assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`) | |
| 495 | 548 | assert.Contains(t, app.renderedHTML, `sv-section`) | |
| 496 | 549 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 497 | 550 | } | |
| @@ -580,12 +633,20 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 580 | 633 | assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB) | |
| 581 | 634 | assert.Contains(t, dom.html, "Saved performance settings") | |
| 582 | 635 | ||
| 636 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 637 | + | Action: configActionSavePerformance, | |
| 638 | + | Data: map[string]string{"profile": "default"}, | |
| 639 | + | })) | |
| 640 | + | assert.Equal(t, "default", service.savedConfig.Performance.Profile) | |
| 641 | + | assert.Contains(t, dom.html, "Saved performance settings") | |
| 642 | + | ||
| 583 | 643 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 584 | 644 | Action: configActionSetKeybinding, | |
| 585 | 645 | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"}, | |
| 586 | 646 | })) | |
| 587 | 647 | assert.True(t, service.calledSet) | |
| 588 | 648 | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | |
| 649 | + | assert.Contains(t, service.setReq.RequestID, "systemviews-config-") | |
| 589 | 650 | assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview") | |
| 590 | 651 | ||
| 591 | 652 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -594,6 +655,8 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 594 | 655 | })) | |
| 595 | 656 | assert.True(t, service.calledReset) | |
| 596 | 657 | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | |
| 658 | + | assert.Contains(t, service.resetReq.RequestID, "systemviews-config-") | |
| 659 | + | assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID) | |
| 597 | 660 | assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview") | |
| 598 | 661 | ||
| 599 | 662 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | |
| @@ -601,6 +664,27 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 601 | 664 | assert.Contains(t, dom.html, "Reset all keybindings to defaults") | |
| 602 | 665 | } | |
| 603 | 666 | ||
| 667 | + | func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) { | |
| 668 | + | t.Parallel() | |
| 669 | + | ||
| 670 | + | service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()} | |
| 671 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |
| 672 | + | ||
| 673 | + | err := app.handleConfigAction(context.Background(), DOMAction{ | |
| 674 | + | Action: configActionSaveSearch, | |
| 675 | + | Data: map[string]string{"default_search_engine": "https://example.com/search"}, | |
| 676 | + | }) | |
| 677 | + | require.Error(t, err) | |
| 678 | + | assert.False(t, service.calledSave) | |
| 679 | + | ||
| 680 | + | err = app.handleConfigAction(context.Background(), DOMAction{ | |
| 681 | + | Action: configActionCreateSearchShortcut, | |
| 682 | + | Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"}, | |
| 683 | + | }) | |
| 684 | + | require.Error(t, err) | |
| 685 | + | assert.False(t, service.calledSave) | |
| 686 | + | } | |
| 687 | + | ||
| 604 | 688 | func testConfigPayload() port.SystemviewConfigPayload { | |
| 605 | 689 | return port.SystemviewConfigPayload{ | |
| 606 | 690 | EngineType: "webkit", | |
| @@ -639,9 +723,9 @@ type fakeDOM struct { | |||
| 639 | 723 | html string | |
| 640 | 724 | } | |
| 641 | 725 | ||
| 642 | - | func (d *fakeDOM) Mount(html string) error { | |
| 726 | + | func (d *fakeDOM) Mount(markup string) error { | |
| 643 | 727 | d.mounted = true | |
| 644 | - | d.html = html | |
| 728 | + | d.html = markup | |
| 645 | 729 | return nil | |
| 646 | 730 | } | |
| 647 | 731 | ||
| @@ -660,6 +744,15 @@ func (d *fakeActionDOM) Release() { | |||
| 660 | 744 | d.released = true | |
| 661 | 745 | } | |
| 662 | 746 | ||
| 747 | + | type failingActionDOM struct { | |
| 748 | + | fakeDOM | |
| 749 | + | bindErr error | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | func (d *failingActionDOM) BindActions(DOMActionHandler) error { | |
| 753 | + | return d.bindErr | |
| 754 | + | } | |
| 755 | + | ||
| 663 | 756 | // Handwritten fake to capture history state for stateful render assertions. | |
| 664 | 757 | type fakeHistoryService struct { | |
| 665 | 758 | called bool | |
| @@ -742,6 +835,7 @@ type fakeFavoritesService struct { | |||
| 742 | 835 | updatedFavorite port.FavoriteUpdateInput | |
| 743 | 836 | deletedFavorite int64 | |
| 744 | 837 | createdFolder string | |
| 838 | + | createdFolderIcon string | |
| 745 | 839 | updatedFolderID int64 | |
| 746 | 840 | deletedFolderID int64 | |
| 747 | 841 | createdTag string | |
| @@ -787,9 +881,10 @@ func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { | |||
| 787 | 881 | ||
| 788 | 882 | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | |
| 789 | 883 | ||
| 790 | - | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name string, _ *int64) (*entity.Folder, error) { | |
| 884 | + | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) { | |
| 791 | 885 | s.createdFolder = name | |
| 792 | - | return &entity.Folder{ID: 77, Name: name}, nil | |
| 886 | + | s.createdFolderIcon = icon | |
| 887 | + | return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil | |
| 793 | 888 | } | |
| 794 | 889 | ||
| 795 | 890 | func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { | |
| @@ -897,3 +992,127 @@ func (s *fakeConfigService) ResetAllKeybindings(context.Context) error { | |||
| 897 | 992 | s.calledResetAll = true | |
| 898 | 993 | return nil | |
| 899 | 994 | } | |
| 995 | + | ||
| 996 | + | func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) { | |
| 997 | + | app := NewApp(Dependencies{Favorites: &fakeFavoritesService{}}) | |
| 998 | + | ||
| 999 | + | err := app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1000 | + | Action: favoriteActionCreate, | |
| 1001 | + | Data: map[string]string{"url": " ", "title": "Blank"}, | |
| 1002 | + | }) | |
| 1003 | + | require.Error(t, err) | |
| 1004 | + | assert.Contains(t, err.Error(), "favorite URL is required") | |
| 1005 | + | ||
| 1006 | + | err = app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1007 | + | Action: favoriteActionCreate, | |
| 1008 | + | Data: map[string]string{"url": "example.com", "title": "Relative"}, | |
| 1009 | + | }) | |
| 1010 | + | require.Error(t, err) | |
| 1011 | + | assert.Contains(t, err.Error(), "favorite URL must be absolute") | |
| 1012 | + | ||
| 1013 | + | err = app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1014 | + | Action: favoriteActionCreate, | |
| 1015 | + | Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"}, | |
| 1016 | + | }) | |
| 1017 | + | require.Error(t, err) | |
| 1018 | + | assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme") | |
| 1019 | + | } | |
| 1020 | + | ||
| 1021 | + | func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |
| 1022 | + | favorites := &fakeFavoritesService{} | |
| 1023 | + | app := NewApp(Dependencies{Favorites: favorites}) | |
| 1024 | + | ||
| 1025 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1026 | + | Action: favoriteActionCreate, | |
| 1027 | + | Data: map[string]string{"url": "dumb:history", "title": "History"}, | |
| 1028 | + | })) | |
| 1029 | + | assert.Equal(t, "dumb://history", favorites.createdFavorite.URL) | |
| 1030 | + | ||
| 1031 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1032 | + | Action: favoriteActionCreate, | |
| 1033 | + | Data: map[string]string{"url": "dumb://config", "title": "Config"}, | |
| 1034 | + | })) | |
| 1035 | + | assert.Equal(t, "dumb://config", favorites.createdFavorite.URL) | |
| 1036 | + | } | |
| 1037 | + | ||
| 1038 | + | func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) { | |
| 1039 | + | favorites := &fakeFavoritesService{} | |
| 1040 | + | app := NewApp(Dependencies{Favorites: favorites}) | |
| 1041 | + | ||
| 1042 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1043 | + | Action: tagActionAssign, | |
| 1044 | + | Data: map[string]string{"favorite_id": "42", "tag_id": "7"}, | |
| 1045 | + | })) | |
| 1046 | + | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | |
| 1047 | + | assert.Equal(t, int64(7), favorites.assignedTagID) | |
| 1048 | + | ||
| 1049 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |
| 1050 | + | Action: tagActionRemove, | |
| 1051 | + | Data: map[string]string{"favorite_id": "42", "tag_id": "7"}, | |
| 1052 | + | })) | |
| 1053 | + | assert.Equal(t, int64(42), favorites.removedFavoriteID) | |
| 1054 | + | assert.Equal(t, int64(7), favorites.removedTagID) | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | func TestHandleHistorySearchClearsDomainFilter(t *testing.T) { | |
| 1058 | + | dom := &fakeDOM{} | |
| 1059 | + | history := &fakeHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}} | |
| 1060 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |
| 1061 | + | app.historyDomainFilter = "example.com" | |
| 1062 | + | ||
| 1063 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 1064 | + | Action: historyActionSearch, | |
| 1065 | + | Data: map[string]string{"query": "search"}, | |
| 1066 | + | })) | |
| 1067 | + | ||
| 1068 | + | assert.Equal(t, "search", app.historyQuery) | |
| 1069 | + | assert.Empty(t, app.historyDomainFilter) | |
| 1070 | + | assert.True(t, history.searchCalled) | |
| 1071 | + | assert.False(t, history.domainCalled) | |
| 1072 | + | } | |
| 1073 | + | ||
| 1074 | + | func TestAppRunWithContextRejectsNilContext(t *testing.T) { | |
| 1075 | + | app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"}) | |
| 1076 | + | ||
| 1077 | + | err := app.RunWithContext(nil) | |
| 1078 | + | ||
| 1079 | + | require.ErrorContains(t, err, "context is nil") | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) { | |
| 1083 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}} | |
| 1084 | + | app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"}) | |
| 1085 | + | ||
| 1086 | + | err := app.RunWithContext(context.Background()) | |
| 1087 | + | ||
| 1088 | + | require.ErrorContains(t, err, "DOM not configured") | |
| 1089 | + | assert.False(t, history.called) | |
| 1090 | + | } | |
| 1091 | + | ||
| 1092 | + | func TestHandleActionsRejectUnknownAction(t *testing.T) { | |
| 1093 | + | app := NewApp(Dependencies{ | |
| 1094 | + | History: &fakeHistoryService{}, | |
| 1095 | + | Favorites: &fakeFavoritesService{}, | |
| 1096 | + | Config: &fakeConfigService{current: testConfigPayload()}, | |
| 1097 | + | }) | |
| 1098 | + | ||
| 1099 | + | require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action") | |
| 1100 | + | require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action") | |
| 1101 | + | require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action") | |
| 1102 | + | } | |
| 1103 | + | ||
| 1104 | + | func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) { | |
| 1105 | + | service := &fakeConfigService{current: testConfigPayload()} | |
| 1106 | + | app := NewApp(Dependencies{Config: service}) | |
| 1107 | + | ||
| 1108 | + | err := app.handleConfigAction(context.Background(), DOMAction{ | |
| 1109 | + | Action: configActionSavePerformance, | |
| 1110 | + | Data: map[string]string{ | |
| 1111 | + | "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2", | |
| 1112 | + | "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4", | |
| 1113 | + | }, | |
| 1114 | + | }) | |
| 1115 | + | ||
| 1116 | + | require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8") | |
| 1117 | + | assert.False(t, service.calledSave) | |
| 1118 | + | } | |
M internal/ui/systemviews/components.templ +2 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package systemviews | |
| 2 | 2 | ||
| 3 | 3 | templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) { | |
| 4 | - | <div class={ appRootClass(theme) } data-route={ string(page.route) } | |
| 4 | + | <div class={ appRootClass(theme) } data-route={ string(page.route) } data-page-title={ pageDocumentTitle(page) } | |
| 5 | 5 | if theme.InlineVars != "" { | |
| 6 | 6 | style={ theme.InlineVars } | |
| 7 | 7 | } | |
| @@ -38,7 +38,7 @@ templ Alert(kind string, message string) { | |||
| 38 | 38 | } | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | templ List(emptyMessage string) { | |
| 41 | + | templ List() { | |
| 42 | 42 | <ul class="sv-list"> | |
| 43 | 43 | { children... } | |
| 44 | 44 | </ul> | |
M internal/ui/systemviews/components_templ.go +133 -120 generated file: collapsed
M internal/ui/systemviews/config.templ +17 -15
| @@ -47,13 +47,13 @@ templ AppearanceForm(config port.SystemviewConfigPayload) { | |||
| 47 | 47 | templ PaletteFields(title string, prefix string, palette port.ColorPalette) { | |
| 48 | 48 | <fieldset class="sv-fieldset"> | |
| 49 | 49 | <legend>{ title }</legend> | |
| 50 | - | <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label> | |
| 51 | - | <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label> | |
| 52 | - | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label> | |
| 53 | - | <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label> | |
| 54 | - | <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label> | |
| 55 | - | <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label> | |
| 56 | - | <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label> | |
| 50 | + | <label><span>Background</span><input name={ prefix + "_background" } type="color" value={ palette.Background } required/></label> | |
| 51 | + | <label><span>Surface</span><input name={ prefix + "_surface" } type="color" value={ palette.Surface } required/></label> | |
| 52 | + | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="color" value={ palette.SurfaceVariant } required/></label> | |
| 53 | + | <label><span>Text</span><input name={ prefix + "_text" } type="color" value={ palette.Text } required/></label> | |
| 54 | + | <label><span>Muted</span><input name={ prefix + "_muted" } type="color" value={ palette.Muted } required/></label> | |
| 55 | + | <label><span>Accent</span><input name={ prefix + "_accent" } type="color" value={ palette.Accent } required/></label> | |
| 56 | + | <label><span>Border</span><input name={ prefix + "_border" } type="color" value={ palette.Border } required/></label> | |
| 57 | 57 | </fieldset> | |
| 58 | 58 | } | |
| 59 | 59 | ||
| @@ -67,7 +67,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) { | |||
| 67 | 67 | <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p> | |
| 68 | 68 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | |
| 69 | 69 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | |
| 70 | - | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label> | |
| 70 | + | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label> | |
| 71 | 71 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | |
| 72 | 72 | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | |
| 73 | 73 | </form> | |
| @@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||
| 89 | 89 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.update"> | |
| 90 | 90 | <input type="hidden" name="key" value={ shortcut.Key }/> | |
| 91 | 91 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | |
| 92 | - | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label> | |
| 92 | + | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label> | |
| 93 | 93 | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | |
| 94 | 94 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 95 | 95 | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | |
| @@ -98,6 +98,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | templ PerformanceSettings(config port.SystemviewConfigPayload) { | |
| 101 | + | {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }} | |
| 101 | 102 | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save"> | |
| 102 | 103 | <label><span>Profile</span><select name="profile"> | |
| 103 | 104 | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | |
| @@ -105,11 +106,12 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) { | |||
| 105 | 106 | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | |
| 106 | 107 | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | |
| 107 | 108 | </select></label> | |
| 108 | - | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label> | |
| 109 | - | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label> | |
| 110 | - | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label> | |
| 111 | - | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label> | |
| 112 | - | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label> | |
| 109 | + | <p class="sv-meta">Choose Custom to edit individual performance values.</p> | |
| 110 | + | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile }/></label> | |
| 111 | + | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile }/></label> | |
| 112 | + | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label> | |
| 113 | + | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label> | |
| 114 | + | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label> | |
| 113 | 115 | <button class="sv-button" type="submit">Save performance</button> | |
| 114 | 116 | </form> | |
| 115 | 117 | <div class="sv-stat-grid"> | |
| @@ -183,7 +185,7 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding | |||
| 183 | 185 | <form class="sv-inline-form" data-sv-action="config.keybinding.set"> | |
| 184 | 186 | <input type="hidden" name="mode" value={ group.Mode }/> | |
| 185 | 187 | <input type="hidden" name="action" value={ binding.Action }/> | |
| 186 | - | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label> | |
| 188 | + | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label> | |
| 187 | 189 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 188 | 190 | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> | |
| 189 | 191 | </form> | |
M internal/ui/systemviews/config_actions.go +154 -31
| @@ -3,26 +3,39 @@ package systemviews | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 7 | + | "net/url" | |
| 6 | 8 | "strconv" | |
| 7 | 9 | "strings" | |
| 10 | + | "sync/atomic" | |
| 8 | 11 | ||
| 9 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| 12 | 15 | const ( | |
| 13 | - | configActionSaveAppearance = "config.appearance.save" | |
| 14 | - | configActionResetAppearance = "config.appearance.reset" | |
| 15 | - | configActionSaveSearch = "config.search.save" | |
| 16 | - | configActionCreateSearchShortcut = "config.searchShortcut.create" | |
| 17 | - | configActionUpdateSearchShortcut = "config.searchShortcut.update" | |
| 18 | - | configActionDeleteSearchShortcut = "config.searchShortcut.delete" | |
| 19 | - | configActionSavePerformance = "config.performance.save" | |
| 20 | - | configActionSetKeybinding = "config.keybinding.set" | |
| 21 | - | configActionResetKeybinding = "config.keybinding.reset" | |
| 22 | - | configActionResetAllKeybindings = "config.keybinding.resetAll" | |
| 23 | - | configRequestIDPrefix = "systemviews-config" | |
| 16 | + | configActionSaveAppearance = "config.appearance.save" | |
| 17 | + | configActionResetAppearance = "config.appearance.reset" | |
| 18 | + | configActionSaveSearch = "config.search.save" | |
| 19 | + | configActionCreateSearchShortcut = "config.searchShortcut.create" | |
| 20 | + | configActionUpdateSearchShortcut = "config.searchShortcut.update" | |
| 21 | + | configActionDeleteSearchShortcut = "config.searchShortcut.delete" | |
| 22 | + | configActionSavePerformance = "config.performance.save" | |
| 23 | + | configActionSetKeybinding = "config.keybinding.set" | |
| 24 | + | configActionResetKeybinding = "config.keybinding.reset" | |
| 25 | + | configActionResetAllKeybindings = "config.keybinding.resetAll" | |
| 26 | + | configRequestIDPrefix = "systemviews-config" | |
| 27 | + | maxCustomPerformanceThreads = 8 | |
| 28 | + | maxCustomPerformanceWebMemoryMB = 16384 | |
| 29 | + | maxCustomPerformanceNetworkMemoryMB = 4096 | |
| 30 | + | maxCustomPerformancePrewarm = 20 | |
| 24 | 31 | ) | |
| 25 | 32 | ||
| 33 | + | var configRequestCounter atomic.Uint64 | |
| 34 | + | ||
| 35 | + | func nextConfigRequestID() string { | |
| 36 | + | return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1)) | |
| 37 | + | } | |
| 38 | + | ||
| 26 | 39 | func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | |
| 27 | 40 | if a.deps.Config == nil { | |
| 28 | 41 | return fmt.Errorf("config service not configured") | |
| @@ -52,8 +65,10 @@ func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | |||
| 52 | 65 | return err | |
| 53 | 66 | } | |
| 54 | 67 | a.configNotice = "Reset all keybindings to defaults" | |
| 68 | + | return nil | |
| 69 | + | default: | |
| 70 | + | return fmt.Errorf("unknown config action: %q", event.Action) | |
| 55 | 71 | } | |
| 56 | - | return nil | |
| 57 | 72 | } | |
| 58 | 73 | ||
| 59 | 74 | func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error { | |
| @@ -70,14 +85,28 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) | |||
| 70 | 85 | if err != nil { | |
| 71 | 86 | return err | |
| 72 | 87 | } | |
| 88 | + | if fontSize <= 0 { | |
| 89 | + | return fmt.Errorf("default font size must be positive") | |
| 90 | + | } | |
| 91 | + | if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) { | |
| 92 | + | return fmt.Errorf("UI scale must be a finite positive number") | |
| 93 | + | } | |
| 73 | 94 | ||
| 74 | 95 | cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"]) | |
| 75 | 96 | cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"]) | |
| 76 | 97 | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | |
| 77 | 98 | cfg.Appearance.DefaultFontSize = fontSize | |
| 78 | 99 | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | |
| 79 | - | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | |
| 80 | - | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | |
| 100 | + | lightPalette, err := paletteFromForm(data, "light") | |
| 101 | + | if err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | darkPalette, err := paletteFromForm(data, "dark") | |
| 105 | + | if err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | cfg.Appearance.LightPalette = lightPalette | |
| 109 | + | cfg.Appearance.DarkPalette = darkPalette | |
| 81 | 110 | cfg.DefaultUIScale = uiScale | |
| 82 | 111 | ||
| 83 | 112 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| @@ -110,7 +139,11 @@ func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) erro | |||
| 110 | 139 | if err != nil { | |
| 111 | 140 | return err | |
| 112 | 141 | } | |
| 113 | - | cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"]) | |
| 142 | + | defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine") | |
| 143 | + | if err != nil { | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | cfg.DefaultSearchEngine = defaultSearchEngine | |
| 114 | 147 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 115 | 148 | return err | |
| 116 | 149 | } | |
| @@ -127,6 +160,9 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) | |||
| 127 | 160 | if key == "" { | |
| 128 | 161 | return fmt.Errorf("search shortcut key is required") | |
| 129 | 162 | } | |
| 163 | + | if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil { | |
| 164 | + | return err | |
| 165 | + | } | |
| 130 | 166 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 131 | 167 | if _, exists := cfg.SearchShortcuts[key]; exists { | |
| 132 | 168 | return fmt.Errorf("search shortcut %q already exists", key) | |
| @@ -152,6 +188,9 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) | |||
| 152 | 188 | if newKey == "" { | |
| 153 | 189 | return fmt.Errorf("new search shortcut key is required") | |
| 154 | 190 | } | |
| 191 | + | if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil { | |
| 192 | + | return err | |
| 193 | + | } | |
| 155 | 194 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 156 | 195 | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | |
| 157 | 196 | return fmt.Errorf("search shortcut %q not found", oldKey) | |
| @@ -197,6 +236,15 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) | |||
| 197 | 236 | return err | |
| 198 | 237 | } | |
| 199 | 238 | profile := strings.TrimSpace(data["profile"]) | |
| 239 | + | cfg.Performance.Profile = profile | |
| 240 | + | if profile != "custom" { | |
| 241 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 242 | + | return err | |
| 243 | + | } | |
| 244 | + | a.configNotice = "Saved performance settings. Restart may be required." | |
| 245 | + | return nil | |
| 246 | + | } | |
| 247 | + | ||
| 200 | 248 | skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads") | |
| 201 | 249 | if err != nil { | |
| 202 | 250 | return err | |
| @@ -217,8 +265,22 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) | |||
| 217 | 265 | if err != nil { | |
| 218 | 266 | return err | |
| 219 | 267 | } | |
| 268 | + | if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil { | |
| 269 | + | return err | |
| 270 | + | } | |
| 271 | + | if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil { | |
| 272 | + | return err | |
| 273 | + | } | |
| 274 | + | if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil { | |
| 278 | + | return err | |
| 279 | + | } | |
| 280 | + | if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil { | |
| 281 | + | return err | |
| 282 | + | } | |
| 220 | 283 | ||
| 221 | - | cfg.Performance.Profile = profile | |
| 222 | 284 | cfg.Performance.Custom = port.WebUICustomPerformanceConfig{ | |
| 223 | 285 | SkiaCPUThreads: skiaCPU, | |
| 224 | 286 | SkiaGPUThreads: skiaGPU, | |
| @@ -244,7 +306,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e | |||
| 244 | 306 | return err | |
| 245 | 307 | } | |
| 246 | 308 | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | |
| 247 | - | RequestID: configRequestIDPrefix, | |
| 309 | + | RequestID: nextConfigRequestID(), | |
| 248 | 310 | Mode: mode, | |
| 249 | 311 | Action: action, | |
| 250 | 312 | Keys: keys, | |
| @@ -270,7 +332,7 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) | |||
| 270 | 332 | return err | |
| 271 | 333 | } | |
| 272 | 334 | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | |
| 273 | - | RequestID: configRequestIDPrefix, | |
| 335 | + | RequestID: nextConfigRequestID(), | |
| 274 | 336 | Mode: mode, | |
| 275 | 337 | Action: action, | |
| 276 | 338 | }); err != nil { | |
| @@ -344,16 +406,57 @@ func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]p | |||
| 344 | 406 | return clone | |
| 345 | 407 | } | |
| 346 | 408 | ||
| 347 | - | func paletteFromForm(data map[string]string, prefix string) port.ColorPalette { | |
| 348 | - | return port.ColorPalette{ | |
| 349 | - | Background: strings.TrimSpace(data[prefix+"_background"]), | |
| 350 | - | Surface: strings.TrimSpace(data[prefix+"_surface"]), | |
| 351 | - | SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]), | |
| 352 | - | Text: strings.TrimSpace(data[prefix+"_text"]), | |
| 353 | - | Muted: strings.TrimSpace(data[prefix+"_muted"]), | |
| 354 | - | Accent: strings.TrimSpace(data[prefix+"_accent"]), | |
| 355 | - | Border: strings.TrimSpace(data[prefix+"_border"]), | |
| 409 | + | func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette, error) { | |
| 410 | + | color := func(field string) (string, error) { | |
| 411 | + | value := strings.TrimSpace(data[prefix+"_"+field]) | |
| 412 | + | if value == "" || isValidHexColor(value) { | |
| 413 | + | return value, nil | |
| 414 | + | } | |
| 415 | + | return "", fmt.Errorf("%s_%s must be a hex color", prefix, field) | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | background, err := color("background") | |
| 419 | + | if err != nil { | |
| 420 | + | return port.ColorPalette{}, err | |
| 421 | + | } | |
| 422 | + | surface, err := color("surface") | |
| 423 | + | if err != nil { | |
| 424 | + | return port.ColorPalette{}, err | |
| 425 | + | } | |
| 426 | + | surfaceVariant, err := color("surface_variant") | |
| 427 | + | if err != nil { | |
| 428 | + | return port.ColorPalette{}, err | |
| 429 | + | } | |
| 430 | + | text, err := color("text") | |
| 431 | + | if err != nil { | |
| 432 | + | return port.ColorPalette{}, err | |
| 433 | + | } | |
| 434 | + | muted, err := color("muted") | |
| 435 | + | if err != nil { | |
| 436 | + | return port.ColorPalette{}, err | |
| 437 | + | } | |
| 438 | + | accent, err := color("accent") | |
| 439 | + | if err != nil { | |
| 440 | + | return port.ColorPalette{}, err | |
| 356 | 441 | } | |
| 442 | + | border, err := color("border") | |
| 443 | + | if err != nil { | |
| 444 | + | return port.ColorPalette{}, err | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | return port.ColorPalette{ | |
| 448 | + | Background: background, | |
| 449 | + | Surface: surface, | |
| 450 | + | SurfaceVariant: surfaceVariant, | |
| 451 | + | Text: text, | |
| 452 | + | Muted: muted, | |
| 453 | + | Accent: accent, | |
| 454 | + | Border: border, | |
| 455 | + | }, nil | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | func isValidHexColor(value string) bool { | |
| 459 | + | return isHexColor(strings.TrimSpace(value)) | |
| 357 | 460 | } | |
| 358 | 461 | ||
| 359 | 462 | func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) { | |
| @@ -363,6 +466,21 @@ func searchShortcutFromForm(data map[string]string, keyField string) (string, po | |||
| 363 | 466 | } | |
| 364 | 467 | } | |
| 365 | 468 | ||
| 469 | + | func requireSearchURLTemplate(raw, label string) (string, error) { | |
| 470 | + | value := strings.TrimSpace(raw) | |
| 471 | + | if value == "" { | |
| 472 | + | return "", fmt.Errorf("%s is required", label) | |
| 473 | + | } | |
| 474 | + | if !strings.Contains(value, "%s") { | |
| 475 | + | return "", fmt.Errorf("%s must include %%s placeholder", label) | |
| 476 | + | } | |
| 477 | + | parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1)) | |
| 478 | + | if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { | |
| 479 | + | return "", fmt.Errorf("%s must be an absolute http(s) URL", label) | |
| 480 | + | } | |
| 481 | + | return value, nil | |
| 482 | + | } | |
| 483 | + | ||
| 366 | 484 | func parseConfigInt(raw, label string) (int, error) { | |
| 367 | 485 | value, err := strconv.Atoi(strings.TrimSpace(raw)) | |
| 368 | 486 | if err != nil { | |
| @@ -379,15 +497,20 @@ func parseConfigFloat(raw, label string) (float64, error) { | |||
| 379 | 497 | return value, nil | |
| 380 | 498 | } | |
| 381 | 499 | ||
| 500 | + | func validatePerformanceInt(value int, label string, minValue, maxValue int) error { | |
| 501 | + | if value < minValue || value > maxValue { | |
| 502 | + | return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue) | |
| 503 | + | } | |
| 504 | + | return nil | |
| 505 | + | } | |
| 506 | + | ||
| 382 | 507 | func parseKeyList(raw string) []string { | |
| 383 | 508 | raw = strings.TrimSpace(raw) | |
| 384 | 509 | if raw == "" { | |
| 385 | 510 | return nil | |
| 386 | 511 | } | |
| 387 | - | parts := strings.Split(raw, ",") | |
| 388 | - | if len(parts) == 1 { | |
| 389 | - | parts = strings.Fields(raw) | |
| 390 | - | } | |
| 512 | + | raw = strings.ReplaceAll(raw, ",", " ") | |
| 513 | + | parts := strings.Fields(raw) | |
| 391 | 514 | keys := make([]string, 0, len(parts)) | |
| 392 | 515 | for _, part := range parts { | |
| 393 | 516 | key := strings.TrimSpace(part) | |
M internal/ui/systemviews/config_templ.go +119 -68 generated file: collapsed
M internal/ui/systemviews/config_view.go +9 -2
| @@ -16,6 +16,7 @@ type configRenderData struct { | |||
| 16 | 16 | Error string | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | // maxFlattenDepth caps recursive config flattening to avoid runaway nesting. | |
| 19 | 20 | const maxFlattenDepth = 16 | |
| 20 | 21 | ||
| 21 | 22 | func configHTML(data configRenderData) string { | |
| @@ -253,6 +254,9 @@ func formatFlatValue(value reflect.Value) string { | |||
| 253 | 254 | case reflect.Complex64, reflect.Complex128: | |
| 254 | 255 | return fmt.Sprint(value.Complex()) | |
| 255 | 256 | default: | |
| 257 | + | if !value.CanInterface() { | |
| 258 | + | return "<unexported>" | |
| 259 | + | } | |
| 256 | 260 | return fmt.Sprint(value.Interface()) | |
| 257 | 261 | } | |
| 258 | 262 | } | |
| @@ -275,7 +279,10 @@ func mapKeyString(value reflect.Value) string { | |||
| 275 | 279 | if value.Kind() == reflect.String { | |
| 276 | 280 | return value.String() | |
| 277 | 281 | } | |
| 278 | - | return fmt.Sprint(value.Interface()) | |
| 282 | + | if value.CanInterface() { | |
| 283 | + | return fmt.Sprint(value.Interface()) | |
| 284 | + | } | |
| 285 | + | return formatFlatValue(value) | |
| 279 | 286 | } | |
| 280 | 287 | ||
| 281 | 288 | func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) { | |
| @@ -344,5 +351,5 @@ func plural(count int, singular string) string { | |||
| 344 | 351 | if count == 1 { | |
| 345 | 352 | return singular | |
| 346 | 353 | } | |
| 347 | - | return strings.TrimSpace(singular + "s") | |
| 354 | + | return singular + "s" | |
| 348 | 355 | } | |
M internal/ui/systemviews/dom.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | type DOM interface { | |
| 9 | - | Mount(html string) error | |
| 9 | + | Mount(markup string) error | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | // DOMAction is a browser event delegated from the mounted systemview DOM. | |
M internal/ui/systemviews/dom_js.go +115 -11
| @@ -28,21 +28,79 @@ func NewDOM() DOM { | |||
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | target := doc.Call("getElementById", "app") | |
| 31 | - | if !target.Truthy() { | |
| 32 | - | target = doc.Get("body") | |
| 33 | - | } | |
| 34 | - | ||
| 35 | 31 | return &browserDOM{document: doc, target: target} | |
| 36 | 32 | } | |
| 37 | 33 | ||
| 38 | - | func (d *browserDOM) Mount(html string) error { | |
| 34 | + | func (d *browserDOM) Mount(markup string) error { | |
| 39 | 35 | if d == nil || !d.target.Truthy() { | |
| 40 | - | return fmt.Errorf("DOM target not available") | |
| 36 | + | return fmt.Errorf("DOM mount target #app not found") | |
| 41 | 37 | } | |
| 42 | - | d.target.Set("innerHTML", html) | |
| 38 | + | d.target.Set("innerHTML", markup) | |
| 39 | + | d.updateDocumentTitle() | |
| 40 | + | d.scheduleAlertDismissal() | |
| 43 | 41 | return nil | |
| 44 | 42 | } | |
| 45 | 43 | ||
| 44 | + | func (d *browserDOM) updateDocumentTitle() { | |
| 45 | + | if d == nil || !d.document.Truthy() || !d.target.Truthy() { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | root := d.target.Call("querySelector", "[data-page-title]") | |
| 49 | + | if !root.Truthy() { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String()) | |
| 53 | + | if title == "" { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | d.document.Set("title", title) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (d *browserDOM) scheduleAlertDismissal() { | |
| 60 | + | if d == nil || !d.target.Truthy() { | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | alerts := d.target.Call("querySelectorAll", ".sv-alert") | |
| 64 | + | length := alerts.Get("length").Int() | |
| 65 | + | if length == 0 { | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | setTimeout := js.Global().Get("setTimeout") | |
| 69 | + | if !setTimeout.Truthy() { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | for i := 0; i < length; i++ { | |
| 73 | + | alert := alerts.Index(i) | |
| 74 | + | if !alert.Truthy() { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | scheduleAlertRemoval(setTimeout, alert) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func scheduleAlertRemoval(setTimeout, alert js.Value) { | |
| 82 | + | var fadeFn js.Func | |
| 83 | + | fadeFn = js.FuncOf(func(js.Value, []js.Value) any { | |
| 84 | + | defer fadeFn.Release() | |
| 85 | + | if !alert.Truthy() || !alert.Get("classList").Truthy() { | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | alert.Get("classList").Call("add", "sv-alert-dismiss") | |
| 89 | + | ||
| 90 | + | var removeFn js.Func | |
| 91 | + | removeFn = js.FuncOf(func(js.Value, []js.Value) any { | |
| 92 | + | defer removeFn.Release() | |
| 93 | + | if alert.Truthy() && alert.Get("remove").Truthy() { | |
| 94 | + | alert.Call("remove") | |
| 95 | + | } | |
| 96 | + | return nil | |
| 97 | + | }) | |
| 98 | + | setTimeout.Invoke(removeFn, 220) | |
| 99 | + | return nil | |
| 100 | + | }) | |
| 101 | + | setTimeout.Invoke(fadeFn, 4200) | |
| 102 | + | } | |
| 103 | + | ||
| 46 | 104 | func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |
| 47 | 105 | if d == nil || !d.target.Truthy() || handler == nil { | |
| 48 | 106 | return fmt.Errorf("DOM action binding target not available") | |
| @@ -58,6 +116,15 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 58 | 116 | if !element.Truthy() { | |
| 59 | 117 | return nil | |
| 60 | 118 | } | |
| 119 | + | if strings.EqualFold(element.Get("tagName").String(), "form") { | |
| 120 | + | target := event.Get("target") | |
| 121 | + | if !target.Truthy() { | |
| 122 | + | target = event.Get("srcElement") | |
| 123 | + | } | |
| 124 | + | if !target.Truthy() || !element.Equal(target) { | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | } | |
| 61 | 128 | action := element.Call("getAttribute", "data-sv-action").String() | |
| 62 | 129 | if action == "" { | |
| 63 | 130 | return nil | |
| @@ -84,6 +151,9 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 84 | 151 | if action == "" { | |
| 85 | 152 | return nil | |
| 86 | 153 | } | |
| 154 | + | if !confirmAction(form) { | |
| 155 | + | return nil | |
| 156 | + | } | |
| 87 | 157 | event.Call("preventDefault") | |
| 88 | 158 | data := collectActionData(form) | |
| 89 | 159 | for key, value := range collectFormData(form) { | |
| @@ -95,7 +165,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 95 | 165 | d.addEventBinding(d.target, "submit", submitHandler) | |
| 96 | 166 | ||
| 97 | 167 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 98 | - | d.handleKeyboard(firstJSArg(args)) | |
| 168 | + | event := firstJSArg(args) | |
| 169 | + | if !d.eventTargetInsideMount(event) { | |
| 170 | + | return nil | |
| 171 | + | } | |
| 172 | + | d.handleKeyboard(event) | |
| 99 | 173 | return nil | |
| 100 | 174 | }) | |
| 101 | 175 | d.addEventBinding(d.document, "keydown", keydownHandler) | |
| @@ -103,6 +177,26 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 103 | 177 | return nil | |
| 104 | 178 | } | |
| 105 | 179 | ||
| 180 | + | func (d *browserDOM) eventTargetInsideMount(event js.Value) bool { | |
| 181 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |
| 182 | + | return false | |
| 183 | + | } | |
| 184 | + | target := event.Get("target") | |
| 185 | + | if !target.Truthy() { | |
| 186 | + | target = event.Get("srcElement") | |
| 187 | + | } | |
| 188 | + | if !target.Truthy() { | |
| 189 | + | return false | |
| 190 | + | } | |
| 191 | + | if d.document.Truthy() && target.Equal(d.document) { | |
| 192 | + | return true | |
| 193 | + | } | |
| 194 | + | if strings.EqualFold(target.Get("nodeName").String(), "body") { | |
| 195 | + | return true | |
| 196 | + | } | |
| 197 | + | return d.target.Call("contains", target).Bool() | |
| 198 | + | } | |
| 199 | + | ||
| 106 | 200 | func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) { | |
| 107 | 201 | if !target.Truthy() { | |
| 108 | 202 | fn.Release() | |
| @@ -172,7 +266,13 @@ func collectFormData(form js.Value) map[string]string { | |||
| 172 | 266 | if pair.Get("length").Int() < 2 { | |
| 173 | 267 | continue | |
| 174 | 268 | } | |
| 175 | - | data[pair.Index(0).String()] = pair.Index(1).String() | |
| 269 | + | key := pair.Index(0).String() | |
| 270 | + | value := pair.Index(1).String() | |
| 271 | + | if existing := data[key]; existing != "" { | |
| 272 | + | data[key] = existing + "," + value | |
| 273 | + | continue | |
| 274 | + | } | |
| 275 | + | data[key] = value | |
| 176 | 276 | } | |
| 177 | 277 | return data | |
| 178 | 278 | } | |
| @@ -232,6 +332,8 @@ func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | |||
| 232 | 332 | } | |
| 233 | 333 | } | |
| 234 | 334 | case "d", "delete", "backspace": | |
| 335 | + | // Delete the focused row through the same delegated click path as pointer users, | |
| 336 | + | // including the data-sv-confirm guard on the delete button. | |
| 235 | 337 | if row := d.activeHistoryRow(); row.Truthy() { | |
| 236 | 338 | event.Call("preventDefault") | |
| 237 | 339 | if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() { | |
| @@ -349,14 +451,16 @@ func (d *browserDOM) focusRow(selector, datasetKey string, delta int) { | |||
| 349 | 451 | func (d *browserDOM) activeRow(selector, datasetKey string) js.Value { | |
| 350 | 452 | rows := d.target.Call("querySelectorAll", selector) | |
| 351 | 453 | length := rows.Get("length").Int() | |
| 352 | - | if length == 0 { | |
| 454 | + | if length <= 0 { | |
| 353 | 455 | return js.Value{} | |
| 354 | 456 | } | |
| 355 | 457 | idx := d.activeIndex(datasetKey) | |
| 356 | 458 | if idx < 0 || idx >= length { | |
| 357 | 459 | idx = 0 | |
| 460 | + | row := rows.Index(idx) | |
| 358 | 461 | d.target.Get("dataset").Set(datasetKey, "0") | |
| 359 | - | rows.Index(0).Get("classList").Call("add", "sv-focused") | |
| 462 | + | row.Get("classList").Call("add", "sv-focused") | |
| 463 | + | return row | |
| 360 | 464 | } | |
| 361 | 465 | return rows.Index(idx) | |
| 362 | 466 | } | |
M internal/ui/systemviews/favorite_actions.go +72 -4
| @@ -3,6 +3,7 @@ package systemviews | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "net/url" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| @@ -38,10 +39,19 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 38 | 39 | if err != nil { | |
| 39 | 40 | return err | |
| 40 | 41 | } | |
| 42 | + | favoriteURL, err := validateFavoriteURL(data["url"]) | |
| 43 | + | if err != nil { | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | tags, err := optionalTagIDs(data["tags"]) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 41 | 50 | favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{ | |
| 42 | - | URL: strings.TrimSpace(data["url"]), | |
| 51 | + | URL: favoriteURL, | |
| 43 | 52 | Title: strings.TrimSpace(data["title"]), | |
| 44 | 53 | FolderID: folderID, | |
| 54 | + | Tags: tags, | |
| 45 | 55 | }) | |
| 46 | 56 | if err != nil { | |
| 47 | 57 | return err | |
| @@ -106,7 +116,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 106 | 116 | if name == "" { | |
| 107 | 117 | return fmt.Errorf("folder name is required") | |
| 108 | 118 | } | |
| 109 | - | folder, err := a.deps.Favorites.CreateFolder(ctx, name, nil) | |
| 119 | + | folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil) | |
| 110 | 120 | if err != nil { | |
| 111 | 121 | return err | |
| 112 | 122 | } | |
| @@ -168,11 +178,11 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 168 | 178 | a.favoriteTagFilter = nil | |
| 169 | 179 | a.favoritesNotice = "Deleted tag" | |
| 170 | 180 | case tagActionAssign, tagActionRemove: | |
| 171 | - | favoriteID, err := parsePositiveInt64(data["favoriteId"], "favorite id") | |
| 181 | + | favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id") | |
| 172 | 182 | if err != nil { | |
| 173 | 183 | return err | |
| 174 | 184 | } | |
| 175 | - | tagID, err := parsePositiveInt64(data["tagId"], "tag id") | |
| 185 | + | tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") | |
| 176 | 186 | if err != nil { | |
| 177 | 187 | return err | |
| 178 | 188 | } | |
| @@ -187,10 +197,40 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 187 | 197 | } | |
| 188 | 198 | a.favoritesNotice = "Removed tag" | |
| 189 | 199 | } | |
| 200 | + | default: | |
| 201 | + | return fmt.Errorf("unknown favorite action: %q", event.Action) | |
| 190 | 202 | } | |
| 191 | 203 | return nil | |
| 192 | 204 | } | |
| 193 | 205 | ||
| 206 | + | func validateFavoriteURL(raw string) (string, error) { | |
| 207 | + | favoriteURL := strings.TrimSpace(raw) | |
| 208 | + | if favoriteURL == "" { | |
| 209 | + | return "", fmt.Errorf("favorite URL is required") | |
| 210 | + | } | |
| 211 | + | parsed, err := url.Parse(favoriteURL) | |
| 212 | + | if err != nil || parsed.Scheme == "" { | |
| 213 | + | return "", fmt.Errorf("favorite URL must be absolute") | |
| 214 | + | } | |
| 215 | + | switch strings.ToLower(parsed.Scheme) { | |
| 216 | + | case "http", "https": | |
| 217 | + | if parsed.Host == "" { | |
| 218 | + | return "", fmt.Errorf("favorite URL host is required") | |
| 219 | + | } | |
| 220 | + | case "dumb": | |
| 221 | + | if parsed.Host != "" { | |
| 222 | + | return favoriteURL, nil | |
| 223 | + | } | |
| 224 | + | if parsed.Opaque != "" { | |
| 225 | + | return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil | |
| 226 | + | } | |
| 227 | + | return "", fmt.Errorf("favorite URL host is required") | |
| 228 | + | default: | |
| 229 | + | return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") | |
| 230 | + | } | |
| 231 | + | return favoriteURL, nil | |
| 232 | + | } | |
| 233 | + | ||
| 194 | 234 | func firstActionValue(data map[string]string, keys ...string) string { | |
| 195 | 235 | for _, key := range keys { | |
| 196 | 236 | if value, ok := data[key]; ok { | |
| @@ -208,6 +248,31 @@ func parsePositiveInt64(raw, label string) (int64, error) { | |||
| 208 | 248 | return id, nil | |
| 209 | 249 | } | |
| 210 | 250 | ||
| 251 | + | func optionalTagIDs(raw string) ([]entity.TagID, error) { | |
| 252 | + | raw = strings.TrimSpace(raw) | |
| 253 | + | if raw == "" { | |
| 254 | + | return nil, nil | |
| 255 | + | } | |
| 256 | + | parts := strings.Split(raw, ",") | |
| 257 | + | ids := make([]entity.TagID, 0, len(parts)) | |
| 258 | + | seen := make(map[entity.TagID]struct{}, len(parts)) | |
| 259 | + | for _, part := range parts { | |
| 260 | + | id, err := parsePositiveInt64(part, "tag id") | |
| 261 | + | if err != nil { | |
| 262 | + | return nil, err | |
| 263 | + | } | |
| 264 | + | tagID := entity.TagID(id) | |
| 265 | + | if _, ok := seen[tagID]; ok { | |
| 266 | + | continue | |
| 267 | + | } | |
| 268 | + | seen[tagID] = struct{}{} | |
| 269 | + | ids = append(ids, tagID) | |
| 270 | + | } | |
| 271 | + | return ids, nil | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // optionalFolderID returns nil for empty or "root" input, meaning no folder constraint; | |
| 275 | + | // otherwise it parses a positive folder id into a non-nil *entity.FolderID. | |
| 211 | 276 | func optionalFolderID(raw string) (*entity.FolderID, error) { | |
| 212 | 277 | raw = strings.TrimSpace(raw) | |
| 213 | 278 | if raw == "" || raw == "root" { | |
| @@ -221,6 +286,8 @@ func optionalFolderID(raw string) (*entity.FolderID, error) { | |||
| 221 | 286 | return &folderID, nil | |
| 222 | 287 | } | |
| 223 | 288 | ||
| 289 | + | // filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter | |
| 290 | + | // favorites in the root folder; other inputs delegate to optionalFolderID. | |
| 224 | 291 | func filterFolderID(raw string) (*entity.FolderID, error) { | |
| 225 | 292 | if strings.TrimSpace(raw) == "root" { | |
| 226 | 293 | root := entity.FolderID(0) | |
| @@ -229,6 +296,7 @@ func filterFolderID(raw string) (*entity.FolderID, error) { | |||
| 229 | 296 | return optionalFolderID(raw) | |
| 230 | 297 | } | |
| 231 | 298 | ||
| 299 | + | // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut. | |
| 232 | 300 | func optionalShortcut(raw string) (*int, error) { | |
| 233 | 301 | raw = strings.TrimSpace(raw) | |
| 234 | 302 | if raw == "" { | |
M internal/ui/systemviews/favorites.templ +23 -4
| @@ -62,16 +62,17 @@ templ FavoritesControls(data favoritesRenderData) { | |||
| 62 | 62 | <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label> | |
| 63 | 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | |
| 64 | 64 | @FolderSelect("folder_id", data.Folders, nil, "Folder") | |
| 65 | + | @FavoriteCreateTagPicker(data.Tags) | |
| 65 | 66 | <button class="sv-button" type="submit">Add favorite</button> | |
| 66 | 67 | </form> | |
| 67 | 68 | <div class="sv-filter-panel"> | |
| 68 | 69 | <div class="sv-button-row"> | |
| 69 | 70 | <span class="sv-meta">Folders</span> | |
| 70 | - | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> | |
| 71 | - | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button> | |
| 71 | + | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> | |
| 72 | + | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button> | |
| 72 | 73 | for _, folder := range data.Folders { | |
| 73 | 74 | if folder != nil { | |
| 74 | - | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button> | |
| 75 | + | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button> | |
| 75 | 76 | } | |
| 76 | 77 | } | |
| 77 | 78 | </div> | |
| @@ -79,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) { | |||
| 79 | 80 | <span class="sv-meta">Tags</span> | |
| 80 | 81 | for _, tag := range data.Tags { | |
| 81 | 82 | if tag != nil { | |
| 82 | - | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | |
| 83 | + | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | |
| 83 | 84 | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name } | |
| 84 | 85 | </button> | |
| 85 | 86 | } | |
| @@ -89,6 +90,24 @@ templ FavoritesControls(data favoritesRenderData) { | |||
| 89 | 90 | <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p> | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 93 | + | templ FavoriteCreateTagPicker(tags []*entity.Tag) { | |
| 94 | + | if len(tags) > 0 { | |
| 95 | + | <fieldset class="sv-tag-picker"> | |
| 96 | + | <legend>Tags</legend> | |
| 97 | + | <div class="sv-tag-controls"> | |
| 98 | + | for _, tag := range tags { | |
| 99 | + | if tag != nil { | |
| 100 | + | <label class="sv-tag-toggle"> | |
| 101 | + | <input type="checkbox" name="tags" value={ fmt.Sprintf("%d", tag.ID) }/> | |
| 102 | + | <span><span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }</span> | |
| 103 | + | </label> | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | </div> | |
| 107 | + | </fieldset> | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 92 | 111 | templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) { | |
| 93 | 112 | <li class="sv-list-row"> | |
| 94 | 113 | <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }> | |
M internal/ui/systemviews/favorites_templ.go +393 -255 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +17 -3
| @@ -21,6 +21,20 @@ func favoritesHTML(data favoritesRenderData) string { | |||
| 21 | 21 | return mustRenderComponent(FavoritesView(data)) | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | func favoritesDocumentTitle(data favoritesRenderData) string { | |
| 25 | + | count := len(data.Favorites) | |
| 26 | + | if data.FolderFilter != nil || data.TagFilter != nil { | |
| 27 | + | if count == 1 { | |
| 28 | + | return "Favorites — 1 filtered bookmark" | |
| 29 | + | } | |
| 30 | + | return fmt.Sprintf("Favorites — %d filtered bookmarks", count) | |
| 31 | + | } | |
| 32 | + | if count == 1 { | |
| 33 | + | return "Favorites — 1 bookmark" | |
| 34 | + | } | |
| 35 | + | return fmt.Sprintf("Favorites — %d bookmarks", count) | |
| 36 | + | } | |
| 37 | + | ||
| 24 | 38 | func favoritesSummary(data favoritesRenderData) string { | |
| 25 | 39 | return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | |
| 26 | 40 | } | |
| @@ -68,7 +82,7 @@ func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) strin | |||
| 68 | 82 | if favorite == nil { | |
| 69 | 83 | return "" | |
| 70 | 84 | } | |
| 71 | - | parts := []string{} | |
| 85 | + | parts := make([]string, 0, 2) | |
| 72 | 86 | if favorite.ShortcutKey != nil { | |
| 73 | 87 | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | |
| 74 | 88 | } | |
| @@ -96,9 +110,9 @@ func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string { | |||
| 96 | 110 | } | |
| 97 | 111 | ||
| 98 | 112 | func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | |
| 99 | - | classes := "sv-button sv-button-secondary" | |
| 113 | + | classes := "sv-button sv-button-secondary sv-tag-button" | |
| 100 | 114 | if favorite != nil && tag != nil && favorite.HasTag(tag.ID) { | |
| 101 | - | classes += " sv-button-active" | |
| 115 | + | classes += " sv-button-active sv-tag-button-active" | |
| 102 | 116 | } | |
| 103 | 117 | return classes | |
| 104 | 118 | } | |
M internal/ui/systemviews/history_actions.go +30 -3
| @@ -25,8 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 25 | 25 | if a == nil { | |
| 26 | 26 | return fmt.Errorf("app is nil") | |
| 27 | 27 | } | |
| 28 | - | a.mu.Lock() | |
| 29 | - | defer a.mu.Unlock() | |
| 28 | + | a.lockState() | |
| 29 | + | defer a.unlockState() | |
| 30 | 30 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | |
| 31 | 31 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 32 | 32 | } | |
| @@ -40,6 +40,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 40 | 40 | } | |
| 41 | 41 | if err := a.loadHistoryRoute(ctx); err != nil { | |
| 42 | 42 | a.renderRouteError(err) | |
| 43 | + | _ = a.mountRenderedHTML() | |
| 44 | + | return err | |
| 43 | 45 | } | |
| 44 | 46 | return a.mountRenderedHTML() | |
| 45 | 47 | case RouteFavorites: | |
| @@ -50,6 +52,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 50 | 52 | } | |
| 51 | 53 | if err := a.loadFavoritesRoute(ctx); err != nil { | |
| 52 | 54 | a.renderRouteError(err) | |
| 55 | + | _ = a.mountRenderedHTML() | |
| 56 | + | return err | |
| 53 | 57 | } | |
| 54 | 58 | return a.mountRenderedHTML() | |
| 55 | 59 | case RouteConfig: | |
| @@ -61,6 +65,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 61 | 65 | a.loadShellTheme(ctx) | |
| 62 | 66 | if err := a.loadConfigRoute(ctx); err != nil { | |
| 63 | 67 | a.renderRouteError(err) | |
| 68 | + | _ = a.mountRenderedHTML() | |
| 69 | + | return err | |
| 64 | 70 | } | |
| 65 | 71 | return a.mountRenderedHTML() | |
| 66 | 72 | default: | |
| @@ -77,6 +83,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 77 | 83 | switch event.Action { | |
| 78 | 84 | case historyActionSearch: | |
| 79 | 85 | a.historyQuery = strings.TrimSpace(data["query"]) | |
| 86 | + | a.historyDomainFilter = "" | |
| 80 | 87 | a.historyOffset = 0 | |
| 81 | 88 | a.historyNotice = "" | |
| 82 | 89 | case historyActionClear: | |
| @@ -90,6 +97,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 90 | 97 | return fmt.Errorf("domain is required") | |
| 91 | 98 | } | |
| 92 | 99 | a.historyDomainFilter = domain | |
| 100 | + | a.historyQuery = "" | |
| 93 | 101 | a.historyOffset = 0 | |
| 94 | 102 | a.historyNotice = "" | |
| 95 | 103 | case historyActionClearDomain: | |
| @@ -111,12 +119,16 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 111 | 119 | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | |
| 112 | 120 | return err | |
| 113 | 121 | } | |
| 122 | + | a.historyOffset = 0 | |
| 114 | 123 | a.historyNotice = "Deleted history entry" | |
| 115 | 124 | case historyActionDeleteRange: | |
| 116 | 125 | rangeID := strings.TrimSpace(data["range"]) | |
| 117 | 126 | if rangeID == "" { | |
| 118 | 127 | return fmt.Errorf("history range is required") | |
| 119 | 128 | } | |
| 129 | + | if !isKnownHistoryCleanupRange(rangeID) { | |
| 130 | + | return fmt.Errorf("invalid history range") | |
| 131 | + | } | |
| 120 | 132 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | |
| 121 | 133 | return err | |
| 122 | 134 | } | |
| @@ -135,15 +147,30 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 135 | 147 | } | |
| 136 | 148 | a.historyOffset = 0 | |
| 137 | 149 | a.historyNotice = "Deleted history for " + domain | |
| 150 | + | default: | |
| 151 | + | return fmt.Errorf("unknown history action: %q", event.Action) | |
| 138 | 152 | } | |
| 139 | 153 | return nil | |
| 140 | 154 | } | |
| 141 | 155 | ||
| 142 | 156 | func (a *App) mountRenderedHTML() error { | |
| 157 | + | return a.mountHTML(a.renderedHTML) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (a *App) mountHTML(html string) error { | |
| 143 | 161 | if a.deps.DOM == nil { | |
| 144 | 162 | return nil | |
| 145 | 163 | } | |
| 146 | - | return a.deps.DOM.Mount(a.renderedHTML) | |
| 164 | + | return a.deps.DOM.Mount(html) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func isKnownHistoryCleanupRange(rangeID string) bool { | |
| 168 | + | for _, item := range historyCleanupItems() { | |
| 169 | + | if item.RangeID == rangeID { | |
| 170 | + | return true | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | return false | |
| 147 | 174 | } | |
| 148 | 175 | ||
| 149 | 176 | func historyRangeNotice(rangeID string) string { | |
M internal/ui/systemviews/history_view.go +40 -29
| @@ -38,11 +38,41 @@ func historyHTML(data historyRenderData) string { | |||
| 38 | 38 | return mustRenderComponent(HistoryView(data)) | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | func historyDocumentTitle(data historyRenderData) string { | |
| 42 | + | if query := strings.TrimSpace(data.Query); query != "" { | |
| 43 | + | return "History — search: " + truncateTitle(query, 48) | |
| 44 | + | } | |
| 45 | + | if domain := strings.TrimSpace(data.DomainFilter); domain != "" { | |
| 46 | + | label := browserurl.DisplayDomain(domain) | |
| 47 | + | if label == "" { | |
| 48 | + | label = domain | |
| 49 | + | } | |
| 50 | + | return "History — " + label | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | entries := int64(countHistoryEntries(data.Entries)) | |
| 54 | + | if data.Analytics != nil { | |
| 55 | + | entries = data.Analytics.TotalEntries | |
| 56 | + | } | |
| 57 | + | if entries == 1 { | |
| 58 | + | return "History — 1 entry" | |
| 59 | + | } | |
| 60 | + | return fmt.Sprintf("History — %d entries", entries) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func truncateTitle(value string, maxRunes int) string { | |
| 64 | + | runes := []rune(strings.TrimSpace(value)) | |
| 65 | + | if maxRunes <= 0 || len(runes) <= maxRunes { | |
| 66 | + | return string(runes) | |
| 67 | + | } | |
| 68 | + | return string(runes[:maxRunes]) + "…" | |
| 69 | + | } | |
| 70 | + | ||
| 41 | 71 | var historyCleanupRanges = []historyCleanupItem{ | |
| 42 | - | {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?", Notice: "Deleted history from the last hour"}, | |
| 43 | - | {Label: "Today", RangeID: "day", Confirm: "Delete history for today?", Notice: "Deleted history from today"}, | |
| 44 | - | {Label: "Week", RangeID: "week", Confirm: "Delete history for week?", Notice: "Deleted history from this week"}, | |
| 45 | - | {Label: "Month", RangeID: "month", Confirm: "Delete history for month?", Notice: "Deleted history from this month"}, | |
| 72 | + | {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"}, | |
| 73 | + | {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"}, | |
| 74 | + | {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"}, | |
| 75 | + | {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"}, | |
| 46 | 76 | {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"}, | |
| 47 | 77 | } | |
| 48 | 78 | ||
| @@ -103,11 +133,6 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i | |||
| 103 | 133 | visits = sumHistoryVisits(data.Entries) | |
| 104 | 134 | uniqueDays = countUniqueHistoryDays(data.Entries) | |
| 105 | 135 | ||
| 106 | - | if data.Analytics != nil { | |
| 107 | - | entries = data.Analytics.TotalEntries | |
| 108 | - | visits = data.Analytics.TotalVisits | |
| 109 | - | uniqueDays = data.Analytics.UniqueDays | |
| 110 | - | } | |
| 111 | 136 | return entries, visits, uniqueDays | |
| 112 | 137 | } | |
| 113 | 138 | ||
| @@ -121,11 +146,12 @@ func historyTimelineEmptyMessage(data historyRenderData) string { | |||
| 121 | 146 | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | |
| 122 | 147 | groups := make([]historyTimelineGroup, 0) | |
| 123 | 148 | indexByDate := map[string]int{} | |
| 149 | + | now := time.Now().Local() | |
| 124 | 150 | for _, entry := range entries { | |
| 125 | 151 | if entry == nil { | |
| 126 | 152 | continue | |
| 127 | 153 | } | |
| 128 | - | dateKey, label := historyDateKeyAndLabel(entry.LastVisited) | |
| 154 | + | dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now) | |
| 129 | 155 | idx, ok := indexByDate[dateKey] | |
| 130 | 156 | if !ok { | |
| 131 | 157 | idx = len(groups) | |
| @@ -137,14 +163,14 @@ func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup | |||
| 137 | 163 | return groups | |
| 138 | 164 | } | |
| 139 | 165 | ||
| 140 | - | func historyDateKeyAndLabel(ts time.Time) (string, string) { | |
| 166 | + | func historyDateKeyAndLabel(ts, now time.Time) (string, string) { | |
| 141 | 167 | if ts.IsZero() { | |
| 142 | 168 | return "unknown", "Unknown date" | |
| 143 | 169 | } | |
| 144 | 170 | ||
| 145 | 171 | local := ts.Local() | |
| 146 | 172 | dateKey := local.Format("2006-01-02") | |
| 147 | - | today := time.Now().Local() | |
| 173 | + | today := now.Local() | |
| 148 | 174 | todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | |
| 149 | 175 | yesterdayStart := todayStart.AddDate(0, 0, -1) | |
| 150 | 176 | entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) | |
| @@ -175,6 +201,8 @@ func sumHistoryVisits(entries []*entity.HistoryEntry) int64 { | |||
| 175 | 201 | if entry == nil { | |
| 176 | 202 | continue | |
| 177 | 203 | } | |
| 204 | + | // Each HistoryEntry represents at least one visit, even when VisitCount | |
| 205 | + | // is unset or non-positive in older persisted rows. | |
| 178 | 206 | if entry.VisitCount <= 0 { | |
| 179 | 207 | visits++ | |
| 180 | 208 | continue | |
| @@ -195,23 +223,6 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||
| 195 | 223 | return int64(len(seen)) | |
| 196 | 224 | } | |
| 197 | 225 | ||
| 198 | - | func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry { | |
| 199 | - | domain = browserurl.CanonicalDomain(domain) | |
| 200 | - | if domain == "" { | |
| 201 | - | return entries | |
| 202 | - | } | |
| 203 | - | filtered := make([]*entity.HistoryEntry, 0, len(entries)) | |
| 204 | - | for _, entry := range entries { | |
| 205 | - | if entry == nil { | |
| 206 | - | continue | |
| 207 | - | } | |
| 208 | - | if browserurl.CanonicalDomain(entry.URL) == domain { | |
| 209 | - | filtered = append(filtered, entry) | |
| 210 | - | } | |
| 211 | - | } | |
| 212 | - | return filtered | |
| 213 | - | } | |
| 214 | - | ||
| 215 | 226 | func displayHistoryDomain(raw string) string { | |
| 216 | 227 | return browserurl.DisplayDomain(raw) | |
| 217 | 228 | } | |
A internal/ui/systemviews/history_view_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | loc := time.FixedZone("test", 2*60*60) | |
| 14 | + | now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc) | |
| 15 | + | ||
| 16 | + | _, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now) | |
| 17 | + | _, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now) | |
| 18 | + | _, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now) | |
| 19 | + | ||
| 20 | + | assert.Equal(t, "Today", todayLabel) | |
| 21 | + | assert.Equal(t, "Yesterday", yesterdayLabel) | |
| 22 | + | assert.Equal(t, "Mon Apr 20", olderLabel) | |
| 23 | + | } | |
M internal/ui/systemviews/href.go +11 -2
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | func sanitizeHref(raw string) string { | |
| 9 | 9 | trimmed := strings.TrimSpace(raw) | |
| 10 | - | if trimmed == "" { | |
| 10 | + | if trimmed == "" || strings.HasPrefix(trimmed, "//") { | |
| 11 | 11 | return "#" | |
| 12 | 12 | } | |
| 13 | 13 | ||
| @@ -24,7 +24,16 @@ func sanitizeHref(raw string) string { | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | switch strings.ToLower(parsed.Scheme) { | |
| 27 | - | case "http", "https", "dumb": | |
| 27 | + | case "http", "https": | |
| 28 | + | if parsed.Host == "" { | |
| 29 | + | return "#" | |
| 30 | + | } | |
| 31 | + | parsed.Scheme = strings.ToLower(parsed.Scheme) | |
| 32 | + | return parsed.String() | |
| 33 | + | case "dumb": | |
| 34 | + | if parsed.Host == "" && parsed.Opaque == "" { | |
| 35 | + | return "#" | |
| 36 | + | } | |
| 28 | 37 | return trimmed | |
| 29 | 38 | default: | |
| 30 | 39 | return "#" | |
M internal/ui/systemviews/href_test.go +18 -0
| @@ -34,6 +34,24 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 34 | 34 | assert.Contains(t, html, `href="https://example.com"`) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | + | func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) { | |
| 38 | + | t.Parallel() | |
| 39 | + | ||
| 40 | + | assert.Equal(t, "#", sanitizeHref("///evil.com")) | |
| 41 | + | assert.Equal(t, "#", sanitizeHref("//evil.com")) | |
| 42 | + | assert.Equal(t, "#", sanitizeHref("https:///evil.com")) | |
| 43 | + | assert.Equal(t, "#", sanitizeHref("http:evil.com")) | |
| 44 | + | assert.Equal(t, "https://example.com", sanitizeHref("https://example.com")) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) { | |
| 48 | + | t.Parallel() | |
| 49 | + | ||
| 50 | + | assert.Equal(t, "dumb://history", sanitizeHref("dumb://history")) | |
| 51 | + | assert.Equal(t, "dumb:history", sanitizeHref("dumb:history")) | |
| 52 | + | assert.Equal(t, "#", sanitizeHref("dumb:///history")) | |
| 53 | + | } | |
| 54 | + | ||
| 37 | 55 | func TestFavoritesHTMLSanitizesTagColors(t *testing.T) { | |
| 38 | 56 | t.Parallel() | |
| 39 | 57 | ||
M internal/ui/systemviews/prefers_dark_js.go +5 -1
| @@ -16,5 +16,9 @@ var currentPrefersDarkImpl = func() bool { | |||
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | media := matchMedia.Invoke("(prefers-color-scheme: dark)") | |
| 19 | - | return media.Truthy() && media.Get("matches").Bool() | |
| 19 | + | if !media.Truthy() { | |
| 20 | + | return false | |
| 21 | + | } | |
| 22 | + | matches := media.Get("matches") | |
| 23 | + | return matches.Truthy() && matches.Bool() | |
| 20 | 24 | } | |
M internal/ui/systemviews/render.go +2 -0
| @@ -19,6 +19,8 @@ func renderComponent(ctx context.Context, component templ.Component) (string, er | |||
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | func mustRenderComponent(component templ.Component) string { | |
| 22 | + | // Render helpers are pure HTML formatting paths; route/action contexts are | |
| 23 | + | // handled before rendering, so there is no caller context to preserve here. | |
| 22 | 24 | html, err := renderComponent(context.Background(), component) | |
| 23 | 25 | if err != nil { | |
| 24 | 26 | return errorStateHTML(err.Error()) | |
M internal/ui/systemviews/shell.go +17 -8
| @@ -17,16 +17,28 @@ type kvPair struct { | |||
| 17 | 17 | // fragment. Route views populate this; the app frame wraps it. | |
| 18 | 18 | type renderedPage struct { | |
| 19 | 19 | route Route | |
| 20 | + | title string | |
| 20 | 21 | subtitle string | |
| 21 | 22 | body string | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>, | |
| 25 | - | // or <body>) that wraps route content in the shared shell. | |
| 26 | + | // or <body>) that wraps route content in the shared shell. page.body is | |
| 27 | + | // inserted with templ.Raw, so callers must only pass trusted pre-rendered HTML. | |
| 26 | 28 | func renderAppFrame(page renderedPage, theme shellTheme) string { | |
| 27 | 29 | return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body))) | |
| 28 | 30 | } | |
| 29 | 31 | ||
| 32 | + | func pageDocumentTitle(page renderedPage) string { | |
| 33 | + | if title := strings.TrimSpace(page.title); title != "" { | |
| 34 | + | return title | |
| 35 | + | } | |
| 36 | + | if subtitle := strings.TrimSpace(page.subtitle); subtitle != "" { | |
| 37 | + | return subtitle | |
| 38 | + | } | |
| 39 | + | return routeSubtitle(page.route) | |
| 40 | + | } | |
| 41 | + | ||
| 30 | 42 | func appRootClass(theme shellTheme) string { | |
| 31 | 43 | themeClass := theme.RootClass | |
| 32 | 44 | if themeClass == "" { | |
| @@ -78,6 +90,7 @@ func listHTML(rows, emptyMessage string) string { | |||
| 78 | 90 | return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows) | |
| 79 | 91 | } | |
| 80 | 92 | ||
| 93 | + | // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item. | |
| 81 | 94 | func listRowHTML(inner string) string { | |
| 82 | 95 | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | |
| 83 | 96 | } | |
| @@ -109,15 +122,11 @@ func kvRowHTML(label, value string) string { | |||
| 109 | 122 | return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value)) | |
| 110 | 123 | } | |
| 111 | 124 | ||
| 112 | - | // sectionHTML wraps content in a styled section with a heading. | |
| 125 | + | // sectionHTML wraps content in a styled section with a heading. inner is | |
| 126 | + | // passed through as raw HTML, so callers must provide trusted markup. | |
| 113 | 127 | func sectionHTML(class, title, inner string) string { | |
| 114 | - | classes := "sv-section" | |
| 115 | - | if strings.TrimSpace(class) != "" { | |
| 116 | - | classes += " " + strings.TrimSpace(class) | |
| 117 | - | } | |
| 118 | - | ||
| 119 | 128 | return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`, | |
| 120 | - | html.EscapeString(classes), | |
| 129 | + | sectionClass(class), | |
| 121 | 130 | html.EscapeString(title), | |
| 122 | 131 | inner, | |
| 123 | 132 | ) | |
M internal/ui/systemviews/shell_test.go +47 -1
| @@ -26,8 +26,9 @@ func TestRenderAppFrame_ReturnsFragment(t *testing.T) { | |||
| 26 | 26 | // Root div carries theme class. | |
| 27 | 27 | assert.Contains(t, out, `class="sv-app sv-dark"`) | |
| 28 | 28 | ||
| 29 | - | // Route is on the root element. | |
| 29 | + | // Route and document title are on the root element. | |
| 30 | 30 | assert.Contains(t, out, `data-route="history"`) | |
| 31 | + | assert.Contains(t, out, `data-page-title="History"`) | |
| 31 | 32 | ||
| 32 | 33 | // Shell wrapper present. | |
| 33 | 34 | assert.Contains(t, out, `class="sv-shell"`) | |
| @@ -52,6 +53,23 @@ func TestRenderAppFrame_DefaultThemeClass(t *testing.T) { | |||
| 52 | 53 | ||
| 53 | 54 | assert.Contains(t, out, `class="sv-app sv-dark"`) | |
| 54 | 55 | assert.Contains(t, out, `data-route="favorites"`) | |
| 56 | + | assert.Contains(t, out, `data-page-title="Favorites"`) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestRenderAppFrame_DocumentTitleUsesExplicitTitle(t *testing.T) { | |
| 60 | + | t.Parallel() | |
| 61 | + | ||
| 62 | + | page := renderedPage{ | |
| 63 | + | route: RouteHistory, | |
| 64 | + | title: "History — 12 entries", | |
| 65 | + | subtitle: "Recent visits", | |
| 66 | + | body: `<p>content</p>`, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | out := renderAppFrame(page, shellTheme{}) | |
| 70 | + | ||
| 71 | + | assert.Contains(t, out, `data-page-title="History — 12 entries"`) | |
| 72 | + | assert.Contains(t, out, "Recent visits") | |
| 55 | 73 | } | |
| 56 | 74 | ||
| 57 | 75 | func TestRenderAppFrame_InlineVarsApplied(t *testing.T) { | |
| @@ -112,6 +130,34 @@ func TestKVHTMLRendersPairs(t *testing.T) { | |||
| 112 | 130 | assert.Contains(t, out, "webkit") | |
| 113 | 131 | } | |
| 114 | 132 | ||
| 133 | + | func TestKVHTMLEmptySlice(t *testing.T) { | |
| 134 | + | t.Parallel() | |
| 135 | + | ||
| 136 | + | out := kvHTML(nil) | |
| 137 | + | ||
| 138 | + | assert.Contains(t, out, `class="sv-empty"`) | |
| 139 | + | assert.Contains(t, out, "No configuration details") | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestKVHTMLAllEmptyRows(t *testing.T) { | |
| 143 | + | t.Parallel() | |
| 144 | + | ||
| 145 | + | out := kvHTML([]kvPair{{}, {Label: " ", Value: ""}}) | |
| 146 | + | ||
| 147 | + | assert.Contains(t, out, `class="sv-empty"`) | |
| 148 | + | assert.Contains(t, out, "No configuration details") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestErrorStateHTML(t *testing.T) { | |
| 152 | + | t.Parallel() | |
| 153 | + | ||
| 154 | + | out := errorStateHTML("boom") | |
| 155 | + | ||
| 156 | + | assert.Contains(t, out, `class="sv-error"`) | |
| 157 | + | assert.Contains(t, out, `role="alert"`) | |
| 158 | + | assert.Contains(t, out, "boom") | |
| 159 | + | } | |
| 160 | + | ||
| 115 | 161 | func TestSectionHTML(t *testing.T) { | |
| 116 | 162 | t.Parallel() | |
| 117 | 163 | ||
M internal/ui/systemviews/theme.go +112 -12
| @@ -3,6 +3,7 @@ package systemviews | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | + | "unicode" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | ) | |
| @@ -17,6 +18,15 @@ const ( | |||
| 17 | 18 | shellLightClass = "sv-light" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 21 | + | var cssNamedColors = func() map[string]struct{} { | |
| 22 | + | names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`) | |
| 23 | + | colors := make(map[string]struct{}, len(names)) | |
| 24 | + | for _, name := range names { | |
| 25 | + | colors[name] = struct{}{} | |
| 26 | + | } | |
| 27 | + | return colors | |
| 28 | + | }() | |
| 29 | + | ||
| 20 | 30 | func currentPrefersDark() bool { | |
| 21 | 31 | return currentPrefersDarkImpl() | |
| 22 | 32 | } | |
| @@ -54,23 +64,24 @@ func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme { | |||
| 54 | 64 | ||
| 55 | 65 | func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string { | |
| 56 | 66 | parts := make([]string, 0, 11) | |
| 57 | - | appendInlineVar := func(name, value string) { | |
| 58 | - | if strings.TrimSpace(value) == "" { | |
| 67 | + | appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) { | |
| 68 | + | value, ok := sanitize(value) | |
| 69 | + | if !ok { | |
| 59 | 70 | return | |
| 60 | 71 | } | |
| 61 | 72 | parts = append(parts, fmt.Sprintf("%s: %s;", name, value)) | |
| 62 | 73 | } | |
| 63 | 74 | ||
| 64 | - | appendInlineVar("--sv-background", palette.Background) | |
| 65 | - | appendInlineVar("--sv-surface", palette.Surface) | |
| 66 | - | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant) | |
| 67 | - | appendInlineVar("--sv-text", palette.Text) | |
| 68 | - | appendInlineVar("--sv-muted", palette.Muted) | |
| 69 | - | appendInlineVar("--sv-accent", palette.Accent) | |
| 70 | - | appendInlineVar("--sv-border", palette.Border) | |
| 71 | - | appendInlineVar("--sv-font-sans", appearance.SansFont) | |
| 72 | - | appendInlineVar("--sv-font-serif", appearance.SerifFont) | |
| 73 | - | appendInlineVar("--sv-font-mono", appearance.MonospaceFont) | |
| 75 | + | appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor) | |
| 76 | + | appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor) | |
| 77 | + | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor) | |
| 78 | + | appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor) | |
| 79 | + | appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor) | |
| 80 | + | appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor) | |
| 81 | + | appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor) | |
| 82 | + | appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily) | |
| 83 | + | appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily) | |
| 84 | + | appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily) | |
| 74 | 85 | if appearance.DefaultFontSize > 0 { | |
| 75 | 86 | parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize)) | |
| 76 | 87 | } | |
| @@ -78,6 +89,95 @@ func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPa | |||
| 78 | 89 | return strings.Join(parts, " ") | |
| 79 | 90 | } | |
| 80 | 91 | ||
| 92 | + | func sanitizeCSSColor(value string) (string, bool) { | |
| 93 | + | value = strings.TrimSpace(value) | |
| 94 | + | if value == "" || hasCSSBreakout(value) { | |
| 95 | + | return "", false | |
| 96 | + | } | |
| 97 | + | lower := strings.ToLower(value) | |
| 98 | + | if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) { | |
| 99 | + | return value, true | |
| 100 | + | } | |
| 101 | + | return "", false | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func sanitizeCSSFontFamily(value string) (string, bool) { | |
| 105 | + | value = strings.TrimSpace(value) | |
| 106 | + | if value == "" || hasCSSBreakout(value) { | |
| 107 | + | return "", false | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | validated := value | |
| 111 | + | if quote := value[0]; quote == '\'' || quote == '"' { | |
| 112 | + | if len(value) < 2 || value[len(value)-1] != quote { | |
| 113 | + | return "", false | |
| 114 | + | } | |
| 115 | + | validated = value[1 : len(value)-1] | |
| 116 | + | if validated == "" || strings.ContainsAny(validated, "'\"") { | |
| 117 | + | return "", false | |
| 118 | + | } | |
| 119 | + | } else if strings.ContainsAny(value, "'\"") { | |
| 120 | + | return "", false | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | for _, r := range validated { | |
| 124 | + | switch { | |
| 125 | + | case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r): | |
| 126 | + | case strings.ContainsRune(" ,._-", r): | |
| 127 | + | default: | |
| 128 | + | return "", false | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | return value, true | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func hasCSSBreakout(value string) bool { | |
| 135 | + | return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func isHexColor(value string) bool { | |
| 139 | + | if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 { | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | if value[0] != '#' { | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | for _, r := range value[1:] { | |
| 146 | + | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { | |
| 147 | + | return false | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | return true | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func isFunctionalColor(value string) bool { | |
| 154 | + | prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("} | |
| 155 | + | matched := false | |
| 156 | + | for _, prefix := range prefixes { | |
| 157 | + | if strings.HasPrefix(value, prefix) { | |
| 158 | + | matched = true | |
| 159 | + | break | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | if !matched || !strings.HasSuffix(value, ")") { | |
| 163 | + | return false | |
| 164 | + | } | |
| 165 | + | for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") { | |
| 166 | + | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) { | |
| 167 | + | return false | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return true | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func isNamedColor(value string) bool { | |
| 174 | + | if value == "transparent" || value == "currentcolor" { | |
| 175 | + | return true | |
| 176 | + | } | |
| 177 | + | _, ok := cssNamedColors[value] | |
| 178 | + | return ok | |
| 179 | + | } | |
| 180 | + | ||
| 81 | 181 | func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool { | |
| 82 | 182 | return appearance.ColorScheme == "" && | |
| 83 | 183 | appearance.SansFont == "" && | |
M internal/ui/systemviews/theme_test.go +60 -0
| @@ -7,6 +7,66 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | vars := buildInlineVars(port.WebUIAppearanceConfig{ | |
| 14 | + | SansFont: "Inter; color:red", | |
| 15 | + | SerifFont: "Georgia", | |
| 16 | + | MonospaceFont: "JetBrains Mono", | |
| 17 | + | LightPalette: port.ColorPalette{ | |
| 18 | + | Background: "#ffffff; color:red", | |
| 19 | + | Surface: "rgb(1 2 3 / 50%)", | |
| 20 | + | Text: "transparent", | |
| 21 | + | Accent: "definitelynotacolor", | |
| 22 | + | Border: "#ABCDEF80", | |
| 23 | + | }, | |
| 24 | + | }, port.ColorPalette{ | |
| 25 | + | Background: "#ffffff; color:red", | |
| 26 | + | Surface: "rgb(1 2 3 / 50%)", | |
| 27 | + | Text: "transparent", | |
| 28 | + | Accent: "definitelynotacolor", | |
| 29 | + | Border: "#ABCDEF80", | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | assert.NotContains(t, vars, "color:red") | |
| 33 | + | assert.NotContains(t, vars, "--sv-background") | |
| 34 | + | assert.NotContains(t, vars, "--sv-font-sans") | |
| 35 | + | assert.Contains(t, vars, "--sv-surface: rgb(1 2 3 / 50%);") | |
| 36 | + | assert.Contains(t, vars, "--sv-text: transparent;") | |
| 37 | + | assert.NotContains(t, vars, "definitelynotacolor") | |
| 38 | + | assert.Contains(t, vars, "--sv-border: #ABCDEF80;") | |
| 39 | + | assert.Contains(t, vars, "--sv-font-serif: Georgia;") | |
| 40 | + | assert.Contains(t, vars, "--sv-font-mono: JetBrains Mono;") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestSanitizeCSSFontFamilyQuotes(t *testing.T) { | |
| 44 | + | t.Parallel() | |
| 45 | + | ||
| 46 | + | tests := []struct { | |
| 47 | + | name string | |
| 48 | + | value string | |
| 49 | + | want bool | |
| 50 | + | }{ | |
| 51 | + | {name: "unquoted spaces", value: "JetBrains Mono", want: true}, | |
| 52 | + | {name: "single quoted token", value: "'JetBrains Mono'", want: true}, | |
| 53 | + | {name: "double quoted token", value: "\"JetBrains Mono\"", want: true}, | |
| 54 | + | {name: "unquoted single quote", value: "JetBrains' Mono", want: false}, | |
| 55 | + | {name: "unquoted double quote", value: "JetBrains\" Mono", want: false}, | |
| 56 | + | {name: "internal quote", value: "'JetBrains' Mono'", want: false}, | |
| 57 | + | {name: "breakout", value: "'JetBrains Mono'; color:red", want: false}, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | ||
| 64 | + | _, got := sanitizeCSSFontFamily(tt.value) | |
| 65 | + | assert.Equal(t, tt.want, got) | |
| 66 | + | }) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 10 | 70 | func TestResolveShellTheme(t *testing.T) { | |
| 11 | 71 | orig := currentPrefersDarkImpl | |
| 12 | 72 | t.Cleanup(func() { | |