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 | "arch=('x86_64')" \ | 51 | "arch=('x86_64')" \ | ||
| 52 | 'url="https://github.com/bnema/dumber"' \ | 52 | 'url="https://github.com/bnema/dumber"' \ | ||
| 53 | "license=('MIT')" \ | 53 | "license=('MIT')" \ | ||
| 54 | - | "makedepends=('go>=1.25' 'git')" \ | 54 | + | "makedepends=('go>=1.26' 'git' 'brotli')" \ |
| 55 | "depends=('gtk4' 'webkitgtk-6.0')" \ | 55 | "depends=('gtk4' 'webkitgtk-6.0')" \ | ||
| 56 | 'optdepends=(' \ | 56 | 'optdepends=(' \ | ||
| 57 | " 'gst-plugins-base: Base media codecs'" \ | 57 | " 'gst-plugins-base: Base media codecs'" \ | ||
M .github/workflows/ci.yml +6 -6
| @@ -47,7 +47,7 @@ jobs: | |||||
| 47 | - name: Install dependencies | 47 | - name: Install dependencies | ||
| 48 | run: | | 48 | run: | | ||
| 49 | pacman -Syu --noconfirm | 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 | - uses: actions/checkout@v6 | 51 | - uses: actions/checkout@v6 | ||
| 52 | - name: Cache Go modules | 52 | - name: Cache Go modules | ||
| 53 | uses: actions/cache@v5 | 53 | uses: actions/cache@v5 | ||
| @@ -63,8 +63,8 @@ jobs: | |||||
| 63 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | 63 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | ||
| 64 | restore-keys: | | 64 | restore-keys: | | ||
| 65 | ${{ runner.os }}-go-build- | 65 | ${{ runner.os }}-go-build- | ||
| 66 | - | - name: Build systemviews assets | 66 | + | - name: Build application and systemviews assets |
| 67 | - | run: make build-systemviews | 67 | + | run: make build |
| 68 | - name: Install golangci-lint | 68 | - name: Install golangci-lint | ||
| 69 | run: | | 69 | run: | | ||
| 70 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest | 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 | - name: Install dependencies | 83 | - name: Install dependencies | ||
| 84 | run: | | 84 | run: | | ||
| 85 | pacman -Syu --noconfirm | 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 | - uses: actions/checkout@v6 | 87 | - uses: actions/checkout@v6 | ||
| 88 | - name: Cache Go modules | 88 | - name: Cache Go modules | ||
| 89 | uses: actions/cache@v5 | 89 | uses: actions/cache@v5 | ||
| @@ -99,8 +99,8 @@ jobs: | |||||
| 99 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | 99 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | ||
| 100 | restore-keys: | | 100 | restore-keys: | | ||
| 101 | ${{ runner.os }}-go-build- | 101 | ${{ runner.os }}-go-build- | ||
| 102 | - | - name: Build systemviews assets | 102 | + | - name: Build application and systemviews assets |
| 103 | - | run: make build-systemviews | 103 | + | run: make build |
| 104 | - name: Run tests | 104 | - name: Run tests | ||
| 105 | env: | 105 | env: | ||
| 106 | GDK_BACKEND: wayland | 106 | GDK_BACKEND: wayland | ||
M .gitignore +1 -0
| @@ -65,6 +65,7 @@ tmp/go-mod/ | |||||
| 65 | tmp/ | 65 | tmp/ | ||
| 66 | assets/webui | 66 | assets/webui | ||
| 67 | assets/systemviews/systemviews.wasm | 67 | assets/systemviews/systemviews.wasm | ||
| 68 | + | assets/systemviews/systemviews.wasm.br | |||
| 68 | assets/systemviews/wasm_exec.js | 69 | assets/systemviews/wasm_exec.js | ||
| 69 | 70 | ||||
| 70 | # Node/TypeScript related | 71 | # Node/TypeScript related | ||
M .mockery.yaml +1 -0
| @@ -88,6 +88,7 @@ packages: | |||||
| 88 | UpdateChecker: {} | 88 | UpdateChecker: {} | ||
| 89 | UpdateDownloader: {} | 89 | UpdateDownloader: {} | ||
| 90 | WebUIConfigSaver: {} | 90 | WebUIConfigSaver: {} | ||
| 91 | + | SystemviewConfigReader: {} | |||
| 91 | XDGPaths: {} | 92 | XDGPaths: {} | ||
| 92 | github.com/bnema/dumber/internal/domain/repository: | 93 | github.com/bnema/dumber/internal/domain/repository: | ||
| 93 | config: | 94 | config: | ||
M CONTRIBUTING.md +1 -1
| @@ -35,7 +35,7 @@ git push origin my-fix | |||||
| 35 | 3. Run `go fmt` on any Go code changes | 35 | 3. Run `go fmt` on any Go code changes | ||
| 36 | 36 | ||||
| 37 | **Systemviews assets:** | 37 | **Systemviews assets:** | ||
| 38 | - | 1. Run `make build-systemviews` to regenerate embedded WASM 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 | Keep commits focused and use single-line commit messages. | 40 | Keep commits focused and use single-line commit messages. | ||
| 41 | 41 | ||||
M Makefile +3 -2
| @@ -53,9 +53,10 @@ generate-systemviews: ## Generate Go code from systemviews templ components | |||||
| 53 | 53 | ||||
| 54 | build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | 54 | build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | ||
| 55 | @echo "Building systemviews wasm assets..." | 55 | @echo "Building systemviews wasm assets..." | ||
| 56 | + | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | |||
| 56 | @mkdir -p assets/systemviews | 57 | @mkdir -p assets/systemviews | ||
| 57 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | 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 | brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br | 60 | brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br | ||
| 60 | @echo "Systemviews build complete" | 61 | @echo "Systemviews build complete" | ||
| 61 | 62 | ||||
| @@ -145,7 +146,7 @@ clean: ## Clean build artifacts | |||||
| 145 | @echo "Cleaning build artifacts..." | 146 | @echo "Cleaning build artifacts..." | ||
| 146 | rm -rf $(DIST_DIR) | 147 | rm -rf $(DIST_DIR) | ||
| 147 | rm -f $(BINARY_NAME) | 148 | rm -f $(BINARY_NAME) | ||
| 148 | - | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm | 149 | + | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br |
| 149 | rm -f coverage.out coverage.html | 150 | rm -f coverage.out coverage.html | ||
| 150 | go clean -cache | 151 | go clean -cache | ||
| 151 | go clean -testcache | 152 | go clean -testcache | ||
M assets/systemviews/index.html +54 -17
| @@ -8,27 +8,64 @@ | |||||
| 8 | <link rel="stylesheet" href="./systemviews.css"> | 8 | <link rel="stylesheet" href="./systemviews.css"> | ||
| 9 | <script src="./wasm_exec.js"></script> | 9 | <script src="./wasm_exec.js"></script> | ||
| 10 | <script> | 10 | <script> | ||
| 11 | - | const go = new Go(); | 11 | + | function renderWasmError(prefixText, err) { |
| 12 | - | WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject) | 12 | + | const render = () => { |
| 13 | - | .then((result) => go.run(result.instance)) | |||
| 14 | - | .catch((err) => { | |||
| 15 | const app = document.getElementById('app'); | 13 | const app = document.getElementById('app'); | ||
| 16 | - | if (app) { | 14 | + | if (!app) return; |
| 17 | - | app.innerHTML = ''; | 15 | + | app.innerHTML = ''; |
| 18 | - | const message = document.createElement('div'); | 16 | + | const message = document.createElement('div'); |
| 19 | - | message.className = 'sv-error'; | 17 | + | message.className = 'sv-error'; |
| 20 | - | const prefix = document.createElement('span'); | 18 | + | const prefix = document.createElement('span'); |
| 21 | - | prefix.textContent = 'Failed to load systemviews WebAssembly: '; | 19 | + | prefix.textContent = prefixText; |
| 22 | - | const details = document.createElement('span'); | 20 | + | const details = document.createElement('span'); |
| 23 | - | details.textContent = err && err.message ? err.message : String(err); | 21 | + | details.textContent = err && err.message ? err.message : String(err || 'unsupported browser'); |
| 24 | - | message.append(prefix, details); | 22 | + | message.append(prefix, details); |
| 25 | - | app.append(message); | 23 | + | app.append(message); |
| 26 | - | } | 24 | + | }; |
| 27 | - | console.error('WASM load error:', err); | 25 | + | if (document.readyState === 'loading' && !document.getElementById('app')) { |
| 28 | - | }); | 26 | + | document.addEventListener('DOMContentLoaded', render, { once: true }); |
| 27 | + | } else { | |||
| 28 | + | render(); | |||
| 29 | + | } | |||
| 30 | + | console.error(prefixText, err); | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | function instantiateWasm(go, wasmURL) { | |||
| 34 | + | const instantiateFromBytes = () => fetch(wasmURL) | |||
| 35 | + | .then((response) => { | |||
| 36 | + | if (!response.ok) { | |||
| 37 | + | throw new Error(`HTTP ${response.status} loading ${wasmURL}`); | |||
| 38 | + | } | |||
| 39 | + | return response.arrayBuffer(); | |||
| 40 | + | }) | |||
| 41 | + | .then((bytes) => WebAssembly.instantiate(bytes, go.importObject)); | |||
| 42 | + | ||||
| 43 | + | if (!WebAssembly.instantiateStreaming) { | |||
| 44 | + | return instantiateFromBytes(); | |||
| 45 | + | } | |||
| 46 | + | return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject) | |||
| 47 | + | .catch((err) => { | |||
| 48 | + | console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err); | |||
| 49 | + | return instantiateFromBytes(); | |||
| 50 | + | }); | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | if (typeof WebAssembly !== 'object') { | |||
| 54 | + | renderWasmError('WebAssembly is not supported in this browser: '); | |||
| 55 | + | } else if (typeof Go !== 'function') { | |||
| 56 | + | renderWasmError('Go WebAssembly runtime failed to load: '); | |||
| 57 | + | } else { | |||
| 58 | + | const go = new Go(); | |||
| 59 | + | instantiateWasm(go, './systemviews.wasm') | |||
| 60 | + | .then((result) => go.run(result.instance)) | |||
| 61 | + | .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err)); | |||
| 62 | + | } | |||
| 29 | </script> | 63 | </script> | ||
| 30 | </head> | 64 | </head> | ||
| 31 | <body> | 65 | <body> | ||
| 32 | <main id="app">Loading systemviews…</main> | 66 | <main id="app">Loading systemviews…</main> | ||
| 67 | + | <noscript> | |||
| 68 | + | <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p> | |||
| 69 | + | </noscript> | |||
| 33 | </body> | 70 | </body> | ||
| 34 | </html> | 71 | </html> | ||
M assets/systemviews/systemviews.css +159 -31
| @@ -1,9 +1,17 @@ | |||||
| 1 | :root { | 1 | :root { | ||
| 2 | - | color-scheme: dark light; | 2 | + | color-scheme: dark; |
| 3 | - | --sv-bg: #0f1115; | 3 | + | --sv-background: #0f1115; |
| 4 | - | --sv-fg: #e8ebf0; | 4 | + | --sv-surface: #171a21; |
| 5 | + | --sv-surface-variant: #1d2129; | |||
| 6 | + | --sv-text: #e8ebf0; | |||
| 5 | --sv-muted: #9aa3b2; | 7 | --sv-muted: #9aa3b2; | ||
| 6 | --sv-border: #2a313d; | 8 | --sv-border: #2a313d; | ||
| 9 | + | --sv-success: var(--sv-accent, #8ab4ff); | |||
| 10 | + | --sv-danger: #ef4444; | |||
| 11 | + | --sv-danger-text: #fecaca; | |||
| 12 | + | --sv-radius-card: 0.5rem; | |||
| 13 | + | --sv-radius-control: 0.35rem; | |||
| 14 | + | --sv-radius-chip: 0.45rem; | |||
| 7 | } | 15 | } | ||
| 8 | 16 | ||||
| 9 | body { | 17 | body { | ||
| @@ -16,8 +24,28 @@ body { | |||||
| 16 | 24 | ||||
| 17 | .sv-app { | 25 | .sv-app { | ||
| 18 | min-height: 100vh; | 26 | min-height: 100vh; | ||
| 27 | + | background: var(--sv-background, #0f1115); | |||
| 28 | + | color: var(--sv-text, #e8ebf0); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | .sv-app.sv-dark { | |||
| 32 | + | color-scheme: dark; | |||
| 19 | } | 33 | } | ||
| 20 | 34 | ||||
| 35 | + | .sv-app.sv-light { | |||
| 36 | + | color-scheme: light; | |||
| 37 | + | --sv-background: #f8fafc; | |||
| 38 | + | --sv-surface: #ffffff; | |||
| 39 | + | --sv-surface-variant: #eef2f7; | |||
| 40 | + | --sv-text: #111827; | |||
| 41 | + | --sv-muted: #64748b; | |||
| 42 | + | --sv-border: #d7dee8; | |||
| 43 | + | --sv-primary: #2563eb; | |||
| 44 | + | --sv-danger: #dc2626; | |||
| 45 | + | --sv-danger-text: #7f1d1d; | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | ||||
| 21 | .sv-shell { | 49 | .sv-shell { | ||
| 22 | display: grid; | 50 | display: grid; | ||
| 23 | gap: 1rem; | 51 | gap: 1rem; | ||
| @@ -52,7 +80,7 @@ body { | |||||
| 52 | .sv-list-row { | 80 | .sv-list-row { | ||
| 53 | padding: 0.75rem; | 81 | padding: 0.75rem; | ||
| 54 | border: 1px solid var(--sv-border, #2a313d); | 82 | border: 1px solid var(--sv-border, #2a313d); | ||
| 55 | - | border-radius: 0.65rem; | 83 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 56 | background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent); | 84 | background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent); | ||
| 57 | } | 85 | } | ||
| 58 | 86 | ||||
| @@ -81,14 +109,14 @@ body { | |||||
| 81 | .sv-header { | 109 | .sv-header { | ||
| 82 | background: var(--sv-surface-variant, #1d2129); | 110 | background: var(--sv-surface-variant, #1d2129); | ||
| 83 | border: 1px solid var(--sv-border, #2a313d); | 111 | border: 1px solid var(--sv-border, #2a313d); | ||
| 84 | - | border-radius: 0.75rem; | 112 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 85 | padding: 1rem; | 113 | padding: 1rem; | ||
| 86 | } | 114 | } | ||
| 87 | 115 | ||||
| 88 | .sv-section { | 116 | .sv-section { | ||
| 89 | background: var(--sv-surface, #171a21); | 117 | background: var(--sv-surface, #171a21); | ||
| 90 | border: 1px solid var(--sv-border, #2a313d); | 118 | border: 1px solid var(--sv-border, #2a313d); | ||
| 91 | - | border-radius: 0.75rem; | 119 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 92 | padding: 1rem; | 120 | padding: 1rem; | ||
| 93 | } | 121 | } | ||
| 94 | 122 | ||||
| @@ -101,7 +129,7 @@ body { | |||||
| 101 | margin-top: 1rem; | 129 | margin-top: 1rem; | ||
| 102 | padding: 1rem; | 130 | padding: 1rem; | ||
| 103 | border: 1px solid var(--sv-border, #2a313d); | 131 | border: 1px solid var(--sv-border, #2a313d); | ||
| 104 | - | border-radius: 0.75rem; | 132 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 105 | background: var(--sv-surface, #171a21); | 133 | background: var(--sv-surface, #171a21); | ||
| 106 | } | 134 | } | ||
| 107 | 135 | ||||
| @@ -156,7 +184,7 @@ body { | |||||
| 156 | display: inline-flex; | 184 | display: inline-flex; | ||
| 157 | align-items: center; | 185 | align-items: center; | ||
| 158 | padding: 0.2rem 0.45rem; | 186 | padding: 0.2rem 0.45rem; | ||
| 159 | - | border-radius: 0.45rem; | 187 | + | border-radius: var(--sv-radius-control, 0.35rem); |
| 160 | border: 1px solid var(--sv-border, #2a313d); | 188 | border: 1px solid var(--sv-border, #2a313d); | ||
| 161 | background: var(--sv-surface-variant, #1d2129); | 189 | background: var(--sv-surface-variant, #1d2129); | ||
| 162 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | 190 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | ||
| @@ -187,7 +215,7 @@ body { | |||||
| 187 | gap: 0.15rem; | 215 | gap: 0.15rem; | ||
| 188 | padding: 0.75rem; | 216 | padding: 0.75rem; | ||
| 189 | border: 1px solid var(--sv-border, #2a313d); | 217 | border: 1px solid var(--sv-border, #2a313d); | ||
| 190 | - | border-radius: 0.65rem; | 218 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 191 | background: var(--sv-surface-variant, #1d2129); | 219 | background: var(--sv-surface-variant, #1d2129); | ||
| 192 | } | 220 | } | ||
| 193 | 221 | ||||
| @@ -216,7 +244,7 @@ body { | |||||
| 216 | gap: 0.15rem; | 244 | gap: 0.15rem; | ||
| 217 | padding: 0.45rem 0.65rem; | 245 | padding: 0.45rem 0.65rem; | ||
| 218 | border: 1px solid var(--sv-border, #2a313d); | 246 | border: 1px solid var(--sv-border, #2a313d); | ||
| 219 | - | border-radius: 999px; | 247 | + | border-radius: var(--sv-radius-chip, 0.45rem); |
| 220 | background: var(--sv-surface-variant, #1d2129); | 248 | background: var(--sv-surface-variant, #1d2129); | ||
| 221 | } | 249 | } | ||
| 222 | 250 | ||||
| @@ -260,9 +288,9 @@ body { | |||||
| 260 | } | 288 | } | ||
| 261 | 289 | ||||
| 262 | .sv-error { | 290 | .sv-error { | ||
| 263 | - | border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | 291 | + | border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d)); |
| 264 | - | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | 292 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21)); |
| 265 | - | border-radius: 0.75rem; | 293 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 266 | color: var(--sv-text, #e8ebf0); | 294 | color: var(--sv-text, #e8ebf0); | ||
| 267 | padding: 1rem; | 295 | padding: 1rem; | ||
| 268 | } | 296 | } | ||
| @@ -304,18 +332,26 @@ body { | |||||
| 304 | font-weight: 600; | 332 | font-weight: 600; | ||
| 305 | } | 333 | } | ||
| 306 | .sv-alert { | 334 | .sv-alert { | ||
| 307 | - | border-radius: 0.75rem; | 335 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 308 | padding: 0.75rem 1rem; | 336 | padding: 0.75rem 1rem; | ||
| 309 | border: 1px solid var(--sv-border, #2a313d); | 337 | border: 1px solid var(--sv-border, #2a313d); | ||
| 338 | + | transition: opacity 180ms ease, transform 180ms ease; | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | .sv-alert-dismiss { | |||
| 342 | + | opacity: 0; | |||
| 343 | + | transform: translateY(-0.25rem); | |||
| 310 | } | 344 | } | ||
| 311 | 345 | ||||
| 312 | .sv-alert-success { | 346 | .sv-alert-success { | ||
| 313 | - | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 14%, var(--sv-surface, #171a21)); | 347 | + | background: color-mix(in srgb, var(--sv-success, #8ab4ff) 14%, var(--sv-surface, #171a21)); |
| 348 | + | border-color: color-mix(in srgb, var(--sv-success, #8ab4ff) 62%, var(--sv-border, #2a313d)); | |||
| 314 | } | 349 | } | ||
| 315 | 350 | ||||
| 316 | .sv-alert-error { | 351 | .sv-alert-error { | ||
| 317 | - | background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21)); | 352 | + | color: var(--sv-danger-text, #fecaca); |
| 318 | - | border-color: color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d)); | 353 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21)); |
| 354 | + | border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 72%, var(--sv-border, #2a313d)); | |||
| 319 | } | 355 | } | ||
| 320 | 356 | ||||
| 321 | .sv-toolbar, | 357 | .sv-toolbar, | ||
| @@ -349,7 +385,7 @@ body { | |||||
| 349 | 385 | ||||
| 350 | .sv-search-label input { | 386 | .sv-search-label input { | ||
| 351 | border: 1px solid var(--sv-border, #2a313d); | 387 | border: 1px solid var(--sv-border, #2a313d); | ||
| 352 | - | border-radius: 0.55rem; | 388 | + | border-radius: var(--sv-radius-control, 0.35rem); |
| 353 | padding: 0.55rem 0.65rem; | 389 | padding: 0.55rem 0.65rem; | ||
| 354 | background: var(--sv-surface-variant, #1d2129); | 390 | background: var(--sv-surface-variant, #1d2129); | ||
| 355 | color: var(--sv-text, #e8ebf0); | 391 | color: var(--sv-text, #e8ebf0); | ||
| @@ -357,12 +393,13 @@ body { | |||||
| 357 | } | 393 | } | ||
| 358 | 394 | ||||
| 359 | .sv-button { | 395 | .sv-button { | ||
| 396 | + | box-sizing: border-box; | |||
| 360 | display: inline-flex; | 397 | display: inline-flex; | ||
| 361 | align-items: center; | 398 | align-items: center; | ||
| 362 | justify-content: center; | 399 | justify-content: center; | ||
| 363 | min-height: 2rem; | 400 | min-height: 2rem; | ||
| 364 | border: 1px solid var(--sv-accent, #8ab4ff); | 401 | border: 1px solid var(--sv-accent, #8ab4ff); | ||
| 365 | - | border-radius: 0.55rem; | 402 | + | border-radius: var(--sv-radius-control, 0.35rem); |
| 366 | padding: 0.35rem 0.65rem; | 403 | padding: 0.35rem 0.65rem; | ||
| 367 | background: var(--sv-accent, #8ab4ff); | 404 | background: var(--sv-accent, #8ab4ff); | ||
| 368 | color: var(--sv-background, #0f1115); | 405 | color: var(--sv-background, #0f1115); | ||
| @@ -379,13 +416,18 @@ body { | |||||
| 379 | } | 416 | } | ||
| 380 | 417 | ||||
| 381 | .sv-button-danger { | 418 | .sv-button-danger { | ||
| 382 | - | border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d)); | 419 | + | border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d)); |
| 383 | - | background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129)); | 420 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129)); |
| 384 | - | color: #fecaca; | 421 | + | color: var(--sv-danger-text, #fecaca); |
| 385 | } | 422 | } | ||
| 386 | 423 | ||||
| 387 | .sv-button-danger:hover { | 424 | .sv-button-danger:hover { | ||
| 388 | - | background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129)); | 425 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 24%, var(--sv-surface-variant, #1d2129)); |
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | .sv-button-danger:disabled { | |||
| 429 | + | color: var(--sv-danger-text, #fecaca); | |||
| 430 | + | opacity: 0.7; | |||
| 389 | } | 431 | } | ||
| 390 | 432 | ||||
| 391 | .sv-button:focus-visible, | 433 | .sv-button:focus-visible, | ||
| @@ -414,7 +456,7 @@ body { | |||||
| 414 | gap: 0.35rem; | 456 | gap: 0.35rem; | ||
| 415 | align-items: center; | 457 | align-items: center; | ||
| 416 | border: 1px solid var(--sv-border, #2a313d); | 458 | border: 1px solid var(--sv-border, #2a313d); | ||
| 417 | - | border-radius: 999px; | 459 | + | border-radius: var(--sv-radius-chip, 0.45rem); |
| 418 | padding: 0.25rem 0.55rem; | 460 | padding: 0.25rem 0.55rem; | ||
| 419 | background: var(--sv-surface-variant, #1d2129); | 461 | background: var(--sv-surface-variant, #1d2129); | ||
| 420 | } | 462 | } | ||
| @@ -446,7 +488,7 @@ body { | |||||
| 446 | } | 488 | } | ||
| 447 | 489 | ||||
| 448 | .sv-danger { | 490 | .sv-danger { | ||
| 449 | - | color: #fecaca; | 491 | + | color: var(--sv-danger-text, #fecaca); |
| 450 | } | 492 | } | ||
| 451 | 493 | ||||
| 452 | .sv-icon-button:hover, | 494 | .sv-icon-button:hover, | ||
| @@ -458,12 +500,12 @@ body { | |||||
| 458 | .sv-history-item.sv-focused { | 500 | .sv-history-item.sv-focused { | ||
| 459 | outline: 2px solid var(--sv-accent, #8ab4ff); | 501 | outline: 2px solid var(--sv-accent, #8ab4ff); | ||
| 460 | outline-offset: 0.35rem; | 502 | outline-offset: 0.35rem; | ||
| 461 | - | border-radius: 0.5rem; | 503 | + | border-radius: var(--sv-radius-control, 0.35rem); |
| 462 | } | 504 | } | ||
| 463 | 505 | ||||
| 464 | .sv-key-hints kbd { | 506 | .sv-key-hints kbd { | ||
| 465 | border: 1px solid var(--sv-border, #2a313d); | 507 | border: 1px solid var(--sv-border, #2a313d); | ||
| 466 | - | border-radius: 0.35rem; | 508 | + | border-radius: 0.25rem; |
| 467 | padding: 0.1rem 0.3rem; | 509 | padding: 0.1rem 0.3rem; | ||
| 468 | background: var(--sv-surface-variant, #1d2129); | 510 | background: var(--sv-surface-variant, #1d2129); | ||
| 469 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | 511 | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | ||
| @@ -481,7 +523,7 @@ body { | |||||
| 481 | .sv-card-form { | 523 | .sv-card-form { | ||
| 482 | padding: 0.75rem; | 524 | padding: 0.75rem; | ||
| 483 | border: 1px solid var(--sv-border, #2a313d); | 525 | border: 1px solid var(--sv-border, #2a313d); | ||
| 484 | - | border-radius: 0.75rem; | 526 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 485 | background: var(--sv-surface-variant, #1d2129); | 527 | background: var(--sv-surface-variant, #1d2129); | ||
| 486 | } | 528 | } | ||
| 487 | 529 | ||||
| @@ -503,13 +545,37 @@ body { | |||||
| 503 | .sv-fieldset input, | 545 | .sv-fieldset input, | ||
| 504 | .sv-fieldset select { | 546 | .sv-fieldset select { | ||
| 505 | border: 1px solid var(--sv-border, #2a313d); | 547 | border: 1px solid var(--sv-border, #2a313d); | ||
| 506 | - | border-radius: 0.55rem; | 548 | + | border-radius: var(--sv-radius-control, 0.35rem); |
| 507 | padding: 0.5rem 0.6rem; | 549 | padding: 0.5rem 0.6rem; | ||
| 508 | background: var(--sv-surface-variant, #1d2129); | 550 | background: var(--sv-surface-variant, #1d2129); | ||
| 509 | color: var(--sv-text, #e8ebf0); | 551 | color: var(--sv-text, #e8ebf0); | ||
| 510 | font: inherit; | 552 | font: inherit; | ||
| 511 | } | 553 | } | ||
| 512 | 554 | ||||
| 555 | + | .sv-card-form input[type="color"], | |||
| 556 | + | .sv-inline-form input[type="color"], | |||
| 557 | + | .sv-fieldset input[type="color"] { | |||
| 558 | + | width: 4rem; | |||
| 559 | + | min-width: 4rem; | |||
| 560 | + | height: 2.1rem; | |||
| 561 | + | padding: 0.15rem; | |||
| 562 | + | background: transparent; | |||
| 563 | + | cursor: pointer; | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | .sv-card-form input[type="color"]::-webkit-color-swatch-wrapper, | |||
| 567 | + | .sv-inline-form input[type="color"]::-webkit-color-swatch-wrapper, | |||
| 568 | + | .sv-fieldset input[type="color"]::-webkit-color-swatch-wrapper { | |||
| 569 | + | padding: 0; | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | .sv-card-form input[type="color"]::-webkit-color-swatch, | |||
| 573 | + | .sv-inline-form input[type="color"]::-webkit-color-swatch, | |||
| 574 | + | .sv-fieldset input[type="color"]::-webkit-color-swatch { | |||
| 575 | + | border: 0; | |||
| 576 | + | border-radius: calc(var(--sv-radius-control, 0.35rem) - 0.12rem); | |||
| 577 | + | } | |||
| 578 | + | ||||
| 513 | .sv-config-form, | 579 | .sv-config-form, | ||
| 514 | .sv-fieldset { | 580 | .sv-fieldset { | ||
| 515 | display: flex; | 581 | display: flex; | ||
| @@ -523,7 +589,7 @@ body { | |||||
| 523 | margin: 0; | 589 | margin: 0; | ||
| 524 | padding: 0.75rem; | 590 | padding: 0.75rem; | ||
| 525 | border: 1px solid var(--sv-border, #2a313d); | 591 | border: 1px solid var(--sv-border, #2a313d); | ||
| 526 | - | border-radius: 0.65rem; | 592 | + | border-radius: var(--sv-radius-card, 0.5rem); |
| 527 | } | 593 | } | ||
| 528 | 594 | ||||
| 529 | .sv-fieldset legend { | 595 | .sv-fieldset legend { | ||
| @@ -545,11 +611,73 @@ body { | |||||
| 545 | margin-top: 0.75rem; | 611 | margin-top: 0.75rem; | ||
| 546 | } | 612 | } | ||
| 547 | 613 | ||||
| 614 | + | .sv-history-row-actions .sv-button { | |||
| 615 | + | min-width: 4.75rem; | |||
| 616 | + | } | |||
| 617 | + | ||||
| 548 | .sv-button-active { | 618 | .sv-button-active { | ||
| 549 | border-color: var(--sv-accent, #8ab4ff); | 619 | border-color: var(--sv-accent, #8ab4ff); | ||
| 550 | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | 620 | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | ||
| 551 | } | 621 | } | ||
| 552 | 622 | ||||
| 623 | + | .sv-tag-picker { | |||
| 624 | + | display: grid; | |||
| 625 | + | gap: 0.45rem; | |||
| 626 | + | min-width: min(22rem, 100%); | |||
| 627 | + | margin: 0; | |||
| 628 | + | padding: 0; | |||
| 629 | + | border: 0; | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | .sv-tag-picker legend { | |||
| 633 | + | color: var(--sv-muted, #9aa3b2); | |||
| 634 | + | font-size: 12px; | |||
| 635 | + | font-weight: 600; | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | .sv-card-form .sv-tag-toggle, | |||
| 639 | + | .sv-tag-toggle { | |||
| 640 | + | display: inline-flex; | |||
| 641 | + | min-width: 0; | |||
| 642 | + | color: inherit; | |||
| 643 | + | font-size: inherit; | |||
| 644 | + | font-weight: inherit; | |||
| 645 | + | cursor: pointer; | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | .sv-tag-toggle input { | |||
| 649 | + | position: absolute; | |||
| 650 | + | inline-size: 1px; | |||
| 651 | + | block-size: 1px; | |||
| 652 | + | opacity: 0; | |||
| 653 | + | pointer-events: none; | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | .sv-tag-toggle span, | |||
| 657 | + | .sv-tag-button { | |||
| 658 | + | display: inline-flex; | |||
| 659 | + | align-items: center; | |||
| 660 | + | gap: 0.35rem; | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | .sv-tag-toggle > span { | |||
| 664 | + | min-height: 1.45rem; | |||
| 665 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 666 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |||
| 667 | + | padding: 0.16rem 0.45rem; | |||
| 668 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 669 | + | color: var(--sv-text, #e8ebf0); | |||
| 670 | + | font-size: 12px; | |||
| 671 | + | font-weight: 700; | |||
| 672 | + | } | |||
| 673 | + | ||||
| 674 | + | .sv-tag-toggle input:checked + span, | |||
| 675 | + | .sv-tag-button-active { | |||
| 676 | + | border-color: var(--sv-accent, #8ab4ff); | |||
| 677 | + | background: var(--sv-accent, #8ab4ff); | |||
| 678 | + | color: var(--sv-background, #0f1115); | |||
| 679 | + | } | |||
| 680 | + | ||||
| 553 | .sv-favorite-item { | 681 | .sv-favorite-item { | ||
| 554 | display: grid; | 682 | display: grid; | ||
| 555 | gap: 0.75rem; | 683 | gap: 0.75rem; | ||
| @@ -581,7 +709,7 @@ body { | |||||
| 581 | .sv-favorite-item.sv-focused { | 709 | .sv-favorite-item.sv-focused { | ||
| 582 | outline: 2px solid var(--sv-accent, #8ab4ff); | 710 | outline: 2px solid var(--sv-accent, #8ab4ff); | ||
| 583 | outline-offset: 0.35rem; | 711 | outline-offset: 0.35rem; | ||
| 584 | - | border-radius: 0.5rem; | 712 | + | border-radius: var(--sv-radius-control, 0.35rem); |
| 585 | } | 713 | } | ||
| 586 | 714 | ||||
| 587 | @media (max-width: 720px) { | 715 | @media (max-width: 720px) { | ||
D assets/systemviews/systemviews.wasm.br +0 -0 binary file: no text diff
binary file: no text diff
M aur/dumber-browser-git/PKGBUILD +1 -1
| @@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)" | |||||
| 6 | arch=('x86_64') | 6 | arch=('x86_64') | ||
| 7 | url="https://github.com/bnema/dumber" | 7 | url="https://github.com/bnema/dumber" | ||
| 8 | license=('MIT') | 8 | license=('MIT') | ||
| 9 | - | makedepends=('go>=1.26' 'git') | 9 | + | makedepends=('go>=1.26' 'git' 'brotli') |
| 10 | depends=('gtk4' 'webkitgtk-6.0') | 10 | depends=('gtk4' 'webkitgtk-6.0') | ||
| 11 | optdepends=( | 11 | optdepends=( | ||
| 12 | 'gst-plugins-base: Base media codecs' | 12 | 'gst-plugins-base: Base media codecs' | ||
M cmd/systemviews/bootstrap.go +4 -1
| @@ -14,11 +14,12 @@ type bridgeServices interface { | |||||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App { | 16 | func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App { | ||
| 17 | + | route := systemviews.ParseRoute(locationURI) | |||
| 17 | return systemviews.NewApp(systemviews.Dependencies{ | 18 | return systemviews.NewApp(systemviews.Dependencies{ | ||
| 18 | DOM: dom, | 19 | DOM: dom, | ||
| 19 | History: bridge, | 20 | History: bridge, | ||
| 20 | Favorites: bridge, | 21 | Favorites: bridge, | ||
| 21 | - | Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)}, | 22 | + | Config: bridgeConfigProxy{bridge: bridge, route: route}, |
| 22 | LocationURI: locationURI, | 23 | LocationURI: locationURI, | ||
| 23 | }) | 24 | }) | ||
| 24 | } | 25 | } | ||
| @@ -41,6 +42,8 @@ func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error | |||||
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 43 | func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | 44 | func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | ||
| 45 | + | // Keybindings are only rendered by dumb://config; skip the bridge load for | |||
| 46 | + | // other systemview routes to keep initial history/favorites loads cheap. | |||
| 44 | if p.route != systemviews.RouteConfig { | 47 | if p.route != systemviews.RouteConfig { | ||
| 45 | return port.KeybindingsConfig{}, nil | 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 | return nil | 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 | type fakeBridgeService struct { | 101 | type fakeBridgeService struct { | ||
| 101 | calledHistory bool | 102 | calledHistory bool | ||
| 102 | calledFavorites bool | 103 | calledFavorites bool | ||
| @@ -166,7 +167,7 @@ func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { retu | |||||
| 166 | 167 | ||||
| 167 | func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil } | 168 | func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil } | ||
| 168 | 169 | ||||
| 169 | - | func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) { | 170 | + | func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) { |
| 170 | return nil, nil | 171 | return nil, nil | ||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
M cmd/systemviews/main.go +5 -2
| @@ -13,8 +13,11 @@ func main() { | |||||
| 13 | bridge := systemviewsbridge.NewBrowserClient() | 13 | bridge := systemviewsbridge.NewBrowserClient() | ||
| 14 | href := "" | 14 | href := "" | ||
| 15 | location := js.Global().Get("location") | 15 | location := js.Global().Get("location") | ||
| 16 | - | if location.Type() != js.TypeUndefined && location.Type() != js.TypeNull { | 16 | + | if location.Truthy() { |
| 17 | - | href = location.Get("href").String() | 17 | + | hrefValue := location.Get("href") |
| 18 | + | if hrefValue.Truthy() && hrefValue.Type() == js.TypeString { | |||
| 19 | + | href = hrefValue.String() | |||
| 20 | + | } | |||
| 18 | } | 21 | } | ||
| 19 | app := newBridgeApp(systemviews.NewDOM(), href, bridge) | 22 | app := newBridgeApp(systemviews.NewDOM(), href, bridge) | ||
| 20 | 23 | ||||
M docs/reference/keybindings.md +5 -5
| @@ -112,19 +112,19 @@ All keybindings can be customized in `~/.config/dumber/config.toml`: | |||||
| 112 | split-right = ["arrowright", "r"] | 112 | split-right = ["arrowright", "r"] | ||
| 113 | close-pane = ["x", "q"] | 113 | close-pane = ["x", "q"] | ||
| 114 | 114 | ||||
| 115 | - | [workspace.shortcuts.actions.close_pane] | 115 | + | [workspace.shortcuts.actions.close-pane] |
| 116 | keys = ["ctrl+w"] | 116 | keys = ["ctrl+w"] | ||
| 117 | 117 | ||||
| 118 | - | [workspace.shortcuts.actions.toggle_floating_pane] | 118 | + | [workspace.shortcuts.actions.toggle-floating-pane] |
| 119 | keys = ["alt+f"] | 119 | keys = ["alt+f"] | ||
| 120 | 120 | ||||
| 121 | - | [workspace.shortcuts.actions.toggle_history_systemview] | 121 | + | [workspace.shortcuts.actions.toggle-history-systemview] |
| 122 | keys = ["ctrl+h"] | 122 | keys = ["ctrl+h"] | ||
| 123 | 123 | ||||
| 124 | - | [workspace.shortcuts.actions.toggle_favorites_systemview] | 124 | + | [workspace.shortcuts.actions.toggle-favorites-systemview] |
| 125 | keys = [] | 125 | keys = [] | ||
| 126 | 126 | ||||
| 127 | - | [workspace.shortcuts.actions.toggle_config_systemview] | 127 | + | [workspace.shortcuts.actions.toggle-config-systemview] |
| 128 | keys = [] | 128 | keys = [] | ||
| 129 | 129 | ||||
| 130 | [workspace.floating_pane] | 130 | [workspace.floating_pane] | ||
M go.mod +1 -1
| @@ -5,7 +5,7 @@ go 1.26.1 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/a-h/templ v0.3.1001 | 6 | github.com/a-h/templ v0.3.1001 | ||
| 7 | github.com/bnema/purego v0.11.0-bnema.2 | 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 | github.com/bnema/purego-ffmpeg v0.3.1 | 9 | github.com/bnema/purego-ffmpeg v0.3.1 | ||
| 10 | github.com/bnema/purego-pipewire v0.1.2 | 10 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 11 | github.com/bnema/purego-sqlite v0.1.2 | 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 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | 61 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | ||
| 62 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | 62 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | ||
| 63 | GetAllFolders(ctx context.Context) ([]*entity.Folder, error) | 63 | GetAllFolders(ctx context.Context) ([]*entity.Folder, error) | ||
| 64 | - | CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) | 64 | + | CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) |
| 65 | DeleteFolder(ctx context.Context, id entity.FolderID) error | 65 | DeleteFolder(ctx context.Context, id entity.FolderID) error | ||
| 66 | UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error | 66 | UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error | ||
| 67 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | 67 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | ||
M internal/application/port/mocks/mocks.go +167 -14
| @@ -7240,8 +7240,8 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C | |||||
| 7240 | } | 7240 | } | ||
| 7241 | 7241 | ||||
| 7242 | // CreateFolder provides a mock function for the type MockHomepageFavorites | 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) { | 7243 | + | func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) { |
| 7244 | - | ret := _mock.Called(ctx, name, parentID) | 7244 | + | ret := _mock.Called(ctx, name, icon, parentID) |
| 7245 | 7245 | ||||
| 7246 | if len(ret) == 0 { | 7246 | if len(ret) == 0 { | ||
| 7247 | panic("no return value specified for CreateFolder") | 7247 | panic("no return value specified for CreateFolder") | ||
| @@ -7249,18 +7249,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin | |||||
| 7249 | 7249 | ||||
| 7250 | var r0 *entity.Folder | 7250 | var r0 *entity.Folder | ||
| 7251 | var r1 error | 7251 | var r1 error | ||
| 7252 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok { | 7252 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok { |
| 7253 | - | return returnFunc(ctx, name, parentID) | 7253 | + | return returnFunc(ctx, name, icon, parentID) |
| 7254 | } | 7254 | } | ||
| 7255 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok { | 7255 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok { |
| 7256 | - | r0 = returnFunc(ctx, name, parentID) | 7256 | + | r0 = returnFunc(ctx, name, icon, parentID) |
| 7257 | } else { | 7257 | } else { | ||
| 7258 | if ret.Get(0) != nil { | 7258 | if ret.Get(0) != nil { | ||
| 7259 | r0 = ret.Get(0).(*entity.Folder) | 7259 | r0 = ret.Get(0).(*entity.Folder) | ||
| 7260 | } | 7260 | } | ||
| 7261 | } | 7261 | } | ||
| 7262 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok { | 7262 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok { |
| 7263 | - | r1 = returnFunc(ctx, name, parentID) | 7263 | + | r1 = returnFunc(ctx, name, icon, parentID) |
| 7264 | } else { | 7264 | } else { | ||
| 7265 | r1 = ret.Error(1) | 7265 | r1 = ret.Error(1) | ||
| 7266 | } | 7266 | } | ||
| @@ -7275,12 +7275,13 @@ type MockHomepageFavorites_CreateFolder_Call struct { | |||||
| 7275 | // CreateFolder is a helper method to define mock.On call | 7275 | // CreateFolder is a helper method to define mock.On call | ||
| 7276 | // - ctx context.Context | 7276 | // - ctx context.Context | ||
| 7277 | // - name string | 7277 | // - name string | ||
| 7278 | + | // - icon string | |||
| 7278 | // - parentID *entity.FolderID | 7279 | // - parentID *entity.FolderID | ||
| 7279 | - | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call { | 7280 | + | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call { |
| 7280 | - | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)} | 7281 | + | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)} |
| 7281 | } | 7282 | } | ||
| 7282 | 7283 | ||||
| 7283 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { | 7284 | + | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { |
| 7284 | _c.Call.Run(func(args mock.Arguments) { | 7285 | _c.Call.Run(func(args mock.Arguments) { | ||
| 7285 | var arg0 context.Context | 7286 | var arg0 context.Context | ||
| 7286 | if args[0] != nil { | 7287 | if args[0] != nil { | ||
| @@ -7290,14 +7291,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont | |||||
| 7290 | if args[1] != nil { | 7291 | if args[1] != nil { | ||
| 7291 | arg1 = args[1].(string) | 7292 | arg1 = args[1].(string) | ||
| 7292 | } | 7293 | } | ||
| 7293 | - | var arg2 *entity.FolderID | 7294 | + | var arg2 string |
| 7294 | if args[2] != nil { | 7295 | if args[2] != nil { | ||
| 7295 | - | arg2 = args[2].(*entity.FolderID) | 7296 | + | arg2 = args[2].(string) |
| 7297 | + | } | |||
| 7298 | + | var arg3 *entity.FolderID | |||
| 7299 | + | if args[3] != nil { | |||
| 7300 | + | arg3 = args[3].(*entity.FolderID) | |||
| 7296 | } | 7301 | } | ||
| 7297 | run( | 7302 | run( | ||
| 7298 | arg0, | 7303 | arg0, | ||
| 7299 | arg1, | 7304 | arg1, | ||
| 7300 | arg2, | 7305 | arg2, | ||
| 7306 | + | arg3, | |||
| 7301 | ) | 7307 | ) | ||
| 7302 | }) | 7308 | }) | ||
| 7303 | return _c | 7309 | return _c | ||
| @@ -7308,7 +7314,7 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder, | |||||
| 7308 | return _c | 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 | _c.Call.Return(run) | 7318 | _c.Call.Return(run) | ||
| 7313 | return _c | 7319 | return _c | ||
| 7314 | } | 7320 | } | ||
| @@ -12079,6 +12085,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context. | |||||
| 12079 | return _c | 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 | // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 12235 | // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 12083 | // The first argument is typically a *testing.T value. | 12236 | // The first argument is typically a *testing.T value. | ||
| 12084 | func NewMockWebUIConfigSaver(t interface { | 12237 | func NewMockWebUIConfigSaver(t interface { | ||
M internal/application/port/systemviews.go +1 -1
| @@ -28,7 +28,7 @@ type SystemviewFavoritesService interface { | |||||
| 28 | ListTags(ctx context.Context) ([]*entity.Tag, error) | 28 | ListTags(ctx context.Context) ([]*entity.Tag, error) | ||
| 29 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | 29 | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | ||
| 30 | SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error | 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 | UpdateFolder(ctx context.Context, id int64, name, icon string) error | 32 | UpdateFolder(ctx context.Context, id int64, name, icon string) error | ||
| 33 | DeleteFolder(ctx context.Context, id int64) error | 33 | DeleteFolder(ctx context.Context, id int64) error | ||
| 34 | CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) | 34 | CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) | ||
M internal/application/port/webui_config.go +1 -7
| @@ -12,13 +12,7 @@ type WebUIConfig struct { | |||||
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| 14 | // SystemviewCustomPerformancePayload is the editable custom performance profile shape. | 14 | // SystemviewCustomPerformancePayload is the editable custom performance profile shape. | ||
| 15 | - | type SystemviewCustomPerformancePayload struct { | 15 | + | type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig |
| 16 | - | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 17 | - | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 18 | - | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 19 | - | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 20 | - | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 21 | - | } | |||
| 22 | 16 | ||||
| 23 | // SystemviewResolvedPerformancePayload is the resolved effective performance shape. | 17 | // SystemviewResolvedPerformancePayload is the resolved effective performance shape. | ||
| 24 | type SystemviewResolvedPerformancePayload struct { | 18 | type SystemviewResolvedPerformancePayload struct { | ||
M internal/application/usecase/manage_favorites.go +96 -21
| @@ -3,6 +3,7 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net/url" | |||
| 6 | "sort" | 7 | "sort" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| @@ -69,6 +70,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 69 | return existing, nil | 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 | fav := entity.NewFavorite(input.URL, input.Title) | 78 | fav := entity.NewFavorite(input.URL, input.Title) | ||
| 73 | fav.FaviconURL = input.FaviconURL | 79 | fav.FaviconURL = input.FaviconURL | ||
| 74 | fav.FolderID = input.FolderID | 80 | fav.FolderID = input.FolderID | ||
| @@ -95,8 +101,14 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 95 | 101 | ||||
| 96 | // AddFavorite creates a favorite from the UI-facing application port. | 102 | // AddFavorite creates a favorite from the UI-facing application port. | ||
| 97 | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | 103 | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.FavoriteCreateInput) (*entity.Favorite, error) { | ||
| 98 | - | if strings.TrimSpace(input.URL) == "" { | 104 | + | favoriteURL, err := normalizeFavoriteURL(input.URL) |
| 99 | - | return nil, fmt.Errorf("favorite URL is required") | 105 | + | if err != nil { |
| 106 | + | return nil, err | |||
| 107 | + | } | |||
| 108 | + | if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil { | |||
| 109 | + | return nil, err | |||
| 110 | + | } else if existing != nil { | |||
| 111 | + | return existing, nil | |||
| 100 | } | 112 | } | ||
| 101 | tags := make([]entity.TagID, 0, len(input.Tags)) | 113 | tags := make([]entity.TagID, 0, len(input.Tags)) | ||
| 102 | for _, tagID := range input.Tags { | 114 | for _, tagID := range input.Tags { | ||
| @@ -104,15 +116,66 @@ func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input port.Fa | |||||
| 104 | tags = append(tags, tagID) | 116 | tags = append(tags, tagID) | ||
| 105 | } | 117 | } | ||
| 106 | } | 118 | } | ||
| 107 | - | return uc.Add(ctx, AddFavoriteInput{ | 119 | + | title := strings.TrimSpace(input.Title) |
| 108 | - | URL: strings.TrimSpace(input.URL), | 120 | + | if title == "" { |
| 109 | - | Title: strings.TrimSpace(input.Title), | 121 | + | title = favoriteURL |
| 122 | + | } | |||
| 123 | + | return uc.addNewFavorite(ctx, AddFavoriteInput{ | |||
| 124 | + | URL: favoriteURL, | |||
| 125 | + | Title: title, | |||
| 110 | FaviconURL: strings.TrimSpace(input.FaviconURL), | 126 | FaviconURL: strings.TrimSpace(input.FaviconURL), | ||
| 111 | FolderID: input.FolderID, | 127 | FolderID: input.FolderID, | ||
| 112 | Tags: tags, | 128 | Tags: tags, | ||
| 113 | }) | 129 | }) | ||
| 114 | } | 130 | } | ||
| 115 | 131 | ||||
| 132 | + | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(ctx context.Context, canonicalURL, rawURL string) (*entity.Favorite, error) { | |||
| 133 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |||
| 136 | + | } | |||
| 137 | + | if existing != nil { | |||
| 138 | + | return existing, nil | |||
| 139 | + | } | |||
| 140 | + | rawURL = strings.TrimSpace(rawURL) | |||
| 141 | + | if rawURL == "" || rawURL == canonicalURL { | |||
| 142 | + | return nil, nil | |||
| 143 | + | } | |||
| 144 | + | existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL) | |||
| 145 | + | if err != nil { | |||
| 146 | + | return nil, fmt.Errorf("failed to check legacy favorite: %w", err) | |||
| 147 | + | } | |||
| 148 | + | return existing, nil | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func normalizeFavoriteURL(raw string) (string, error) { | |||
| 152 | + | favoriteURL := domainurl.Normalize(strings.TrimSpace(raw)) | |||
| 153 | + | if favoriteURL == "" { | |||
| 154 | + | return "", fmt.Errorf("favorite URL is required") | |||
| 155 | + | } | |||
| 156 | + | parsed, err := url.Parse(favoriteURL) | |||
| 157 | + | if err != nil || parsed.Scheme == "" { | |||
| 158 | + | return "", fmt.Errorf("favorite URL must be absolute") | |||
| 159 | + | } | |||
| 160 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 161 | + | case "http", "https": | |||
| 162 | + | if parsed.Host == "" { | |||
| 163 | + | return "", fmt.Errorf("favorite URL host is required") | |||
| 164 | + | } | |||
| 165 | + | case "dumb": | |||
| 166 | + | if parsed.Host != "" { | |||
| 167 | + | return favoriteURL, nil | |||
| 168 | + | } | |||
| 169 | + | if parsed.Opaque != "" { | |||
| 170 | + | return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil | |||
| 171 | + | } | |||
| 172 | + | return "", fmt.Errorf("favorite URL host is required") | |||
| 173 | + | default: | |||
| 174 | + | return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") | |||
| 175 | + | } | |||
| 176 | + | return favoriteURL, nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 116 | // UpdateFavorite updates editable favorite metadata from the UI-facing application port. | 179 | // UpdateFavorite updates editable favorite metadata from the UI-facing application port. | ||
| 117 | func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | 180 | func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | ||
| 118 | if input.ID <= 0 { | 181 | if input.ID <= 0 { | ||
| @@ -130,7 +193,11 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input port | |||||
| 130 | return nil, fmt.Errorf("favorite %d not found", input.ID) | 193 | return nil, fmt.Errorf("favorite %d not found", input.ID) | ||
| 131 | } | 194 | } | ||
| 132 | 195 | ||||
| 133 | - | fav.Title = strings.TrimSpace(input.Title) | 196 | + | title := strings.TrimSpace(input.Title) |
| 197 | + | if title == "" { | |||
| 198 | + | return nil, fmt.Errorf("favorite title is required") | |||
| 199 | + | } | |||
| 200 | + | fav.Title = title | |||
| 134 | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | 201 | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | ||
| 135 | fav.FolderID = input.FolderID | 202 | fav.FolderID = input.FolderID | ||
| 136 | fav.ShortcutKey = input.ShortcutKey | 203 | fav.ShortcutKey = input.ShortcutKey | ||
| @@ -375,18 +442,20 @@ type ToggleResult struct { | |||||
| 375 | 442 | ||||
| 376 | // Toggle adds or removes a URL from favorites based on current state. | 443 | // Toggle adds or removes a URL from favorites based on current state. | ||
| 377 | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | 444 | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | ||
| 378 | - | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | 445 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) { |
| 379 | - | log := logging.FromContext(ctx) | 446 | + | favoriteURL, err := normalizeFavoriteURL(rawURL) |
| 380 | - | log.Debug().Str("url", url).Msg("toggling favorite") | 447 | + | if err != nil { |
| 381 | - | 448 | + | return nil, err | |
| 382 | - | if url == "" { | |||
| 383 | - | return nil, fmt.Errorf("URL cannot be empty") | |||
| 384 | } | 449 | } | ||
| 450 | + | title = strings.TrimSpace(title) | |||
| 385 | 451 | ||||
| 386 | - | // Check if already favorited | 452 | + | log := logging.FromContext(ctx) |
| 387 | - | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | 453 | + | log.Debug().Str("url", favoriteURL).Msg("toggling favorite") |
| 454 | + | ||||
| 455 | + | // Check if already favorited, including legacy rows stored before URL canonicalization. | |||
| 456 | + | existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) | |||
| 388 | if err != nil { | 457 | if err != nil { | ||
| 389 | - | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | 458 | + | return nil, err |
| 390 | } | 459 | } | ||
| 391 | 460 | ||||
| 392 | if existing != nil { | 461 | if existing != nil { | ||
| @@ -394,26 +463,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||||
| 394 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | 463 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | ||
| 395 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | 464 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | ||
| 396 | } | 465 | } | ||
| 397 | - | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | 466 | + | log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") |
| 398 | uc.invalidateCache() | 467 | uc.invalidateCache() | ||
| 399 | return &ToggleResult{ | 468 | return &ToggleResult{ | ||
| 400 | Added: false, | 469 | Added: false, | ||
| 401 | - | URL: url, | 470 | + | URL: favoriteURL, |
| 402 | Title: existing.Title, | 471 | Title: existing.Title, | ||
| 403 | Message: "Favorite removed", | 472 | Message: "Favorite removed", | ||
| 404 | }, nil | 473 | }, nil | ||
| 405 | } | 474 | } | ||
| 406 | 475 | ||||
| 407 | // Add favorite | 476 | // Add favorite | ||
| 408 | - | fav := entity.NewFavorite(url, title) | 477 | + | fav := entity.NewFavorite(favoriteURL, title) |
| 409 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | 478 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | ||
| 410 | return nil, fmt.Errorf("failed to add favorite: %w", err) | 479 | return nil, fmt.Errorf("failed to add favorite: %w", err) | ||
| 411 | } | 480 | } | ||
| 412 | - | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | 481 | + | log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") |
| 413 | uc.invalidateCache() | 482 | uc.invalidateCache() | ||
| 414 | return &ToggleResult{ | 483 | return &ToggleResult{ | ||
| 415 | Added: true, | 484 | Added: true, | ||
| 416 | - | URL: url, | 485 | + | URL: favoriteURL, |
| 417 | Title: title, | 486 | Title: title, | ||
| 418 | Message: "Favorite added", | 487 | Message: "Favorite added", | ||
| 419 | }, nil | 488 | }, nil | ||
| @@ -509,11 +578,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite | |||||
| 509 | } | 578 | } | ||
| 510 | 579 | ||||
| 511 | // CreateFolder creates a new folder. | 580 | // CreateFolder creates a new folder. | ||
| 512 | - | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) { | 581 | + | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) { |
| 582 | + | name = strings.TrimSpace(name) | |||
| 583 | + | icon = strings.TrimSpace(icon) | |||
| 584 | + | if name == "" { | |||
| 585 | + | return nil, fmt.Errorf("folder name is required") | |||
| 586 | + | } | |||
| 513 | log := logging.FromContext(ctx) | 587 | log := logging.FromContext(ctx) | ||
| 514 | log.Debug().Str("name", name).Msg("creating folder") | 588 | log.Debug().Str("name", name).Msg("creating folder") | ||
| 515 | 589 | ||||
| 516 | folder := entity.NewFolder(name) | 590 | folder := entity.NewFolder(name) | ||
| 591 | + | folder.Icon = icon | |||
| 517 | folder.ParentID = parentID | 592 | folder.ParentID = parentID | ||
| 518 | 593 | ||||
| 519 | if err := uc.folderRepo.Save(ctx, folder); err != nil { | 594 | if err := uc.folderRepo.Save(ctx, folder); err != nil { | ||
M internal/application/usecase/manage_favorites_test.go +92 -1
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 8 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | 10 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | ||
| @@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |||||
| 142 | result, err := uc.Toggle(ctx, "", "Title") | 143 | result, err := uc.Toggle(ctx, "", "Title") | ||
| 143 | require.Error(t, err) | 144 | require.Error(t, err) | ||
| 144 | assert.Nil(t, result) | 145 | assert.Nil(t, result) | ||
| 145 | - | assert.Contains(t, err.Error(), "URL cannot be empty") | 146 | + | assert.Contains(t, err.Error(), "favorite URL is required") |
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) { | |||
| 150 | + | ctx := testContext() | |||
| 151 | + | ||||
| 152 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 153 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 154 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 155 | + | ||||
| 156 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |||
| 157 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once() | |||
| 158 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 159 | + | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | |||
| 160 | + | })).Return(nil).Once() | |||
| 161 | + | ||||
| 162 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 163 | + | ||||
| 164 | + | result, err := uc.Toggle(ctx, " example.com ", " Example ") | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | require.NotNil(t, result) | |||
| 167 | + | assert.True(t, result.Added) | |||
| 168 | + | assert.Equal(t, "https://example.com", result.URL) | |||
| 169 | + | assert.Equal(t, "Example", result.Title) | |||
| 146 | } | 170 | } | ||
| 147 | 171 | ||||
| 148 | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | 172 | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | ||
| @@ -342,3 +366,70 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin | |||||
| 342 | require.Len(t, favoritesAfter, 1) | 366 | require.Len(t, favoritesAfter, 1) | ||
| 343 | assert.Nil(t, favoritesAfter[0].FolderID) | 367 | assert.Nil(t, favoritesAfter[0].FolderID) | ||
| 344 | } | 368 | } | ||
| 369 | + | ||||
| 370 | + | func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | |||
| 371 | + | ctx := testContext() | |||
| 372 | + | ||||
| 373 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 374 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 375 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 376 | + | ||||
| 377 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |||
| 378 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once() | |||
| 379 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 380 | + | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | |||
| 381 | + | })).Return(nil).Once() | |||
| 382 | + | ||||
| 383 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 384 | + | ||||
| 385 | + | fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "example.com", Title: " Example "}) | |||
| 386 | + | require.NoError(t, err) | |||
| 387 | + | require.NotNil(t, fav) | |||
| 388 | + | assert.Equal(t, "https://example.com", fav.URL) | |||
| 389 | + | assert.Equal(t, "Example", fav.Title) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | |||
| 393 | + | ctx := testContext() | |||
| 394 | + | ||||
| 395 | + | tests := []string{ | |||
| 396 | + | "", | |||
| 397 | + | "not a url", | |||
| 398 | + | "javascript:alert(1)", | |||
| 399 | + | "data:text/html,<script>alert(1)</script>", | |||
| 400 | + | "https:///missing-host", | |||
| 401 | + | "dumb:///missing-host", | |||
| 402 | + | } | |||
| 403 | + | for _, raw := range tests { | |||
| 404 | + | t.Run(raw, func(t *testing.T) { | |||
| 405 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 406 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 407 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 408 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 409 | + | ||||
| 410 | + | _, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: raw}) | |||
| 411 | + | require.Error(t, err) | |||
| 412 | + | }) | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) { | |||
| 417 | + | ctx := testContext() | |||
| 418 | + | ||||
| 419 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 420 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 421 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 422 | + | ||||
| 423 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once() | |||
| 424 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once() | |||
| 425 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 426 | + | return fav != nil && fav.URL == "dumb://history" && fav.Title == "History" | |||
| 427 | + | })).Return(nil).Once() | |||
| 428 | + | ||||
| 429 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 430 | + | ||||
| 431 | + | fav, err := uc.AddFavorite(ctx, port.FavoriteCreateInput{URL: "dumb:history", Title: "History"}) | |||
| 432 | + | require.NoError(t, err) | |||
| 433 | + | require.NotNil(t, fav) | |||
| 434 | + | assert.Equal(t, "dumb://history", fav.URL) | |||
| 435 | + | } | |||
M internal/application/usecase/read_systemview_config.go +9 -3
| @@ -2,7 +2,7 @@ package usecase | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "fmt" | 5 | + | "errors" |
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | ) | 8 | ) | ||
| @@ -14,13 +14,19 @@ type ReadSystemviewConfigUseCase struct { | |||||
| 14 | 14 | ||||
| 15 | // NewReadSystemviewConfigUseCase creates a new read systemview config use case. | 15 | // NewReadSystemviewConfigUseCase creates a new read systemview config use case. | ||
| 16 | func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase { | 16 | func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase { | ||
| 17 | + | if reader == nil { | |||
| 18 | + | panic("NewReadSystemviewConfigUseCase: reader is nil") | |||
| 19 | + | } | |||
| 17 | return &ReadSystemviewConfigUseCase{reader: reader} | 20 | return &ReadSystemviewConfigUseCase{reader: reader} | ||
| 18 | } | 21 | } | ||
| 19 | 22 | ||||
| 23 | + | // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil. | |||
| 24 | + | var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil") | |||
| 25 | + | ||||
| 20 | // Current returns the current systemview config payload. | 26 | // Current returns the current systemview config payload. | ||
| 21 | func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | 27 | func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | ||
| 22 | if uc == nil || uc.reader == nil { | 28 | if uc == nil || uc.reader == nil { | ||
| 23 | - | return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil") | 29 | + | return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader |
| 24 | } | 30 | } | ||
| 25 | return uc.reader.Current(ctx) | 31 | return uc.reader.Current(ctx) | ||
| 26 | } | 32 | } | ||
| @@ -28,7 +34,7 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.System | |||||
| 28 | // Default returns the default systemview config payload. | 34 | // Default returns the default systemview config payload. | ||
| 29 | func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | 35 | func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | ||
| 30 | if uc == nil || uc.reader == nil { | 36 | if uc == nil || uc.reader == nil { | ||
| 31 | - | return port.SystemviewConfigPayload{}, fmt.Errorf("systemview config reader is nil") | 37 | + | return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader |
| 32 | } | 38 | } | ||
| 33 | return uc.reader.Default(ctx) | 39 | return uc.reader.Default(ctx) | ||
| 34 | } | 40 | } | ||
M internal/application/usecase/read_systemview_config_test.go +21 -22
| @@ -5,48 +5,47 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 8 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | func TestReadSystemviewConfigUseCase_Current(t *testing.T) { | 13 | func TestReadSystemviewConfigUseCase_Current(t *testing.T) { | ||
| 12 | - | reader := &stubSystemviewConfigReader{ | 14 | + | reader := portmocks.NewMockSystemviewConfigReader(t) |
| 13 | - | current: port.SystemviewConfigPayload{EngineType: "webkit"}, | 15 | + | want := port.SystemviewConfigPayload{EngineType: "webkit"} |
| 14 | - | } | 16 | + | reader.EXPECT().Current(mock.Anything).Return(want, nil).Once() |
| 15 | 17 | ||||
| 16 | uc := NewReadSystemviewConfigUseCase(reader) | 18 | uc := NewReadSystemviewConfigUseCase(reader) | ||
| 17 | 19 | ||||
| 18 | got, err := uc.Current(context.Background()) | 20 | got, err := uc.Current(context.Background()) | ||
| 19 | require.NoError(t, err) | 21 | require.NoError(t, err) | ||
| 20 | - | require.Equal(t, reader.current, got) | 22 | + | require.Equal(t, want, got) |
| 21 | - | require.Equal(t, []string{"current"}, reader.calls) | |||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | func TestReadSystemviewConfigUseCase_Default(t *testing.T) { | 25 | func TestReadSystemviewConfigUseCase_Default(t *testing.T) { | ||
| 25 | - | reader := &stubSystemviewConfigReader{ | 26 | + | reader := portmocks.NewMockSystemviewConfigReader(t) |
| 26 | - | defaultPayload: port.SystemviewConfigPayload{EngineType: "webkit"}, | 27 | + | want := port.SystemviewConfigPayload{EngineType: "webkit"} |
| 27 | - | } | 28 | + | reader.EXPECT().Default(mock.Anything).Return(want, nil).Once() |
| 28 | 29 | ||||
| 29 | uc := NewReadSystemviewConfigUseCase(reader) | 30 | uc := NewReadSystemviewConfigUseCase(reader) | ||
| 30 | 31 | ||||
| 31 | got, err := uc.Default(context.Background()) | 32 | got, err := uc.Default(context.Background()) | ||
| 32 | require.NoError(t, err) | 33 | require.NoError(t, err) | ||
| 33 | - | require.Equal(t, reader.defaultPayload, got) | 34 | + | require.Equal(t, want, got) |
| 34 | - | require.Equal(t, []string{"default"}, reader.calls) | |||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | // Handwritten fake to capture stateful config reads for assertions. | 37 | + | func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) { |
| 38 | - | type stubSystemviewConfigReader struct { | 38 | + | require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() { |
| 39 | - | current port.SystemviewConfigPayload | 39 | + | NewReadSystemviewConfigUseCase(nil) |
| 40 | - | defaultPayload port.SystemviewConfigPayload | 40 | + | }) |
| 41 | - | calls []string | |||
| 42 | } | 41 | } | ||
| 43 | 42 | ||||
| 44 | - | func (r *stubSystemviewConfigReader) Current(context.Context) (port.SystemviewConfigPayload, error) { | 43 | + | func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) { |
| 45 | - | r.calls = append(r.calls, "current") | 44 | + | var uc *ReadSystemviewConfigUseCase |
| 46 | - | return r.current, nil | 45 | + | |
| 47 | - | } | 46 | + | _, err := uc.Current(context.Background()) |
| 47 | + | require.ErrorIs(t, err, ErrNilSystemviewConfigReader) | |||
| 48 | 48 | ||||
| 49 | - | func (r *stubSystemviewConfigReader) Default(context.Context) (port.SystemviewConfigPayload, error) { | 49 | + | _, err = uc.Default(context.Background()) |
| 50 | - | r.calls = append(r.calls, "default") | 50 | + | require.ErrorIs(t, err, ErrNilSystemviewConfigReader) |
| 51 | - | return r.defaultPayload, nil | |||
| 52 | } | 51 | } | ||
M internal/application/usecase/save_webui_config.go +3 -2
| @@ -3,6 +3,7 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -55,8 +56,8 @@ func validateWebUIConfig(cfg port.WebUIConfig) error { | |||||
| 55 | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | 56 | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | ||
| 56 | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | 57 | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | ||
| 57 | } | 58 | } | ||
| 58 | - | if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { | 59 | + | if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { |
| 59 | - | errs = append(errs, "default_ui_scale must be between 0.5 and 3.0") | 60 | + | errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0") |
| 60 | } | 61 | } | ||
| 61 | 62 | ||||
| 62 | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | 63 | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | ||
M internal/application/usecase/save_webui_config_test.go +28 -22
| @@ -2,10 +2,13 @@ package usecase_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "math" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 8 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 9 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| @@ -36,13 +39,28 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) { | |||||
| 36 | }, | 39 | }, | ||
| 37 | wantErr: "performance.profile", | 40 | wantErr: "performance.profile", | ||
| 38 | }, | 41 | }, | ||
| 42 | + | { | |||
| 43 | + | name: "rejects NaN UI scale", | |||
| 44 | + | mutate: func(cfg *port.WebUIConfig) { | |||
| 45 | + | cfg.DefaultUIScale = math.NaN() | |||
| 46 | + | }, | |||
| 47 | + | wantErr: "default_ui_scale must be a finite value", | |||
| 48 | + | }, | |||
| 49 | + | { | |||
| 50 | + | name: "rejects infinite UI scale", | |||
| 51 | + | mutate: func(cfg *port.WebUIConfig) { | |||
| 52 | + | cfg.DefaultUIScale = math.Inf(1) | |||
| 53 | + | }, | |||
| 54 | + | wantErr: "default_ui_scale must be a finite value", | |||
| 55 | + | }, | |||
| 39 | } | 56 | } | ||
| 40 | 57 | ||||
| 41 | for _, tt := range tests { | 58 | for _, tt := range tests { | ||
| 42 | t.Run(tt.name, func(t *testing.T) { | 59 | t.Run(tt.name, func(t *testing.T) { | ||
| 43 | cfg := validWebUIConfig() | 60 | cfg := validWebUIConfig() | ||
| 44 | tt.mutate(&cfg) | 61 | tt.mutate(&cfg) | ||
| 45 | - | uc := usecase.NewSaveWebUIConfigUseCase(fakeWebUIConfigSaver{}) | 62 | + | saver := portmocks.NewMockWebUIConfigSaver(t) |
| 63 | + | uc := usecase.NewSaveWebUIConfigUseCase(saver) | |||
| 46 | 64 | ||||
| 47 | err := uc.Execute(context.Background(), cfg) | 65 | err := uc.Execute(context.Background(), cfg) | ||
| 48 | 66 | ||||
| @@ -53,7 +71,11 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) { | |||||
| 53 | } | 71 | } | ||
| 54 | 72 | ||||
| 55 | func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | 73 | func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | ||
| 56 | - | saver := &capturingWebUIConfigSaver{} | 74 | + | saver := portmocks.NewMockWebUIConfigSaver(t) |
| 75 | + | var saved port.WebUIConfig | |||
| 76 | + | saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("port.WebUIConfig")).Run(func(_ context.Context, cfg port.WebUIConfig) { | |||
| 77 | + | saved = cfg | |||
| 78 | + | }).Return(nil).Once() | |||
| 57 | uc := usecase.NewSaveWebUIConfigUseCase(saver) | 79 | uc := usecase.NewSaveWebUIConfigUseCase(saver) | ||
| 58 | cfg := validWebUIConfig() | 80 | cfg := validWebUIConfig() | ||
| 59 | cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "} | 81 | cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "} | ||
| @@ -61,9 +83,10 @@ func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | |||||
| 61 | err := uc.Execute(context.Background(), cfg) | 83 | err := uc.Execute(context.Background(), cfg) | ||
| 62 | 84 | ||||
| 63 | require.NoError(t, err) | 85 | require.NoError(t, err) | ||
| 64 | - | require.Contains(t, saver.saved.SearchShortcuts, "ddg") | 86 | + | require.NotContains(t, saved.SearchShortcuts, " ddg ") |
| 65 | - | require.Equal(t, "https://duckduckgo.com/?q=%s", saver.saved.SearchShortcuts["ddg"].URL) | 87 | + | require.Contains(t, saved.SearchShortcuts, "ddg") |
| 66 | - | require.Equal(t, "DuckDuckGo", saver.saved.SearchShortcuts["ddg"].Description) | 88 | + | require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL) |
| 89 | + | require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description) | |||
| 67 | } | 90 | } | ||
| 68 | 91 | ||||
| 69 | func validWebUIConfig() port.WebUIConfig { | 92 | func validWebUIConfig() port.WebUIConfig { | ||
| @@ -94,20 +117,3 @@ func validWebUIConfig() port.WebUIConfig { | |||||
| 94 | SearchShortcuts: map[string]port.SearchShortcut{}, | 117 | SearchShortcuts: map[string]port.SearchShortcut{}, | ||
| 95 | } | 118 | } | ||
| 96 | } | 119 | } | ||
| 97 | - | ||||
| 98 | - | // Handwritten fake to preserve stateful save assertions without mock generation. | |||
| 99 | - | type fakeWebUIConfigSaver struct{} | |||
| 100 | - | ||||
| 101 | - | func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error { | |||
| 102 | - | return nil | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // Handwritten fake to capture the saved config state for assertions. | |||
| 106 | - | type capturingWebUIConfigSaver struct { | |||
| 107 | - | saved port.WebUIConfig | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func (s *capturingWebUIConfigSaver) SaveWebUIConfig(_ context.Context, cfg port.WebUIConfig) error { | |||
| 111 | - | s.saved = cfg | |||
| 112 | - | return nil | |||
| 113 | - | } | |||
M internal/application/usecase/search_history.go +24 -8
| @@ -81,9 +81,9 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st | |||||
| 81 | if limit <= 0 { | 81 | if limit <= 0 { | ||
| 82 | limit = 50 | 82 | limit = 50 | ||
| 83 | } | 83 | } | ||
| 84 | - | domain = domainurl.CanonicalDomain(domain) | 84 | + | domain, err := canonicalHistoryDomain(domain) |
| 85 | - | if domain == "" { | 85 | + | if err != nil { |
| 86 | - | return nil, fmt.Errorf("domain is required") | 86 | + | return nil, err |
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset) | 89 | entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset) | ||
| @@ -93,6 +93,16 @@ func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain st | |||||
| 93 | return entries, nil | 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 | // GetRecentSince retrieves history entries visited within the last N days. | 106 | // GetRecentSince retrieves history entries visited within the last N days. | ||
| 97 | // If days is 0, returns all history entries. | 107 | // If days is 0, returns all history entries. | ||
| 98 | // If days is negative, defaults to 30 days. | 108 | // If days is negative, defaults to 30 days. | ||
| @@ -173,7 +183,7 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time. | |||||
| 173 | return nil | 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 | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | 187 | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | ||
| 178 | cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | 188 | cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | ||
| 179 | if !ok { | 189 | if !ok { | ||
| @@ -183,7 +193,13 @@ func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) | |||||
| 183 | return uc.ClearAll(ctx) | 193 | return uc.ClearAll(ctx) | ||
| 184 | } | 194 | } | ||
| 185 | 195 | ||||
| 186 | - | return uc.ClearOlderThan(ctx, cutoff) | 196 | + | log := logging.FromContext(ctx) |
| 197 | + | log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range") | |||
| 198 | + | if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil { | |||
| 199 | + | return fmt.Errorf("failed to clear history range: %w", err) | |||
| 200 | + | } | |||
| 201 | + | log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared") | |||
| 202 | + | return nil | |||
| 187 | } | 203 | } | ||
| 188 | 204 | ||||
| 189 | // ClearAll deletes all history entries. | 205 | // ClearAll deletes all history entries. | ||
| @@ -214,9 +230,9 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error { | |||||
| 214 | 230 | ||||
| 215 | // DeleteByDomain removes all history entries for a domain. | 231 | // DeleteByDomain removes all history entries for a domain. | ||
| 216 | func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error { | 232 | func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error { | ||
| 217 | - | domain = domainurl.CanonicalDomain(domain) | 233 | + | domain, err := canonicalHistoryDomain(domain) |
| 218 | - | if domain == "" { | 234 | + | if err != nil { |
| 219 | - | return fmt.Errorf("domain is required") | 235 | + | return err |
| 220 | } | 236 | } | ||
| 221 | 237 | ||||
| 222 | log := logging.FromContext(ctx) | 238 | log := logging.FromContext(ctx) | ||
M internal/application/usecase/search_history_test.go +57 -0
| @@ -152,6 +152,39 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin | |||||
| 152 | assert.Empty(t, result) | 152 | assert.Empty(t, result) | ||
| 153 | } | 153 | } | ||
| 154 | 154 | ||||
| 155 | + | func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) { | |||
| 156 | + | ctx := testContext() | |||
| 157 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 158 | + | historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool { | |||
| 159 | + | return !since.IsZero() && time.Since(since) <= time.Hour+time.Minute | |||
| 160 | + | })).Return(nil).Once() | |||
| 161 | + | ||||
| 162 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 163 | + | ||||
| 164 | + | err := uc.ClearRange(ctx, "hour") | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) { | |||
| 169 | + | ctx := testContext() | |||
| 170 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 171 | + | today := time.Now() | |||
| 172 | + | historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool { | |||
| 173 | + | return since.Year() == today.Year() && | |||
| 174 | + | since.Month() == today.Month() && | |||
| 175 | + | since.Day() == today.Day() && | |||
| 176 | + | since.Hour() == 0 && | |||
| 177 | + | since.Minute() == 0 && | |||
| 178 | + | since.Second() == 0 && | |||
| 179 | + | since.Nanosecond() == 0 | |||
| 180 | + | })).Return(nil).Once() | |||
| 181 | + | ||||
| 182 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 183 | + | ||||
| 184 | + | err := uc.ClearRange(ctx, "day") | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 155 | func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) { | 188 | func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) { | ||
| 156 | ctx := testContext() | 189 | ctx := testContext() | ||
| 157 | 190 | ||||
| @@ -176,3 +209,27 @@ func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) | |||||
| 176 | require.Error(t, err) | 209 | require.Error(t, err) | ||
| 177 | assert.Contains(t, err.Error(), "unknown history delete range") | 210 | assert.Contains(t, err.Error(), "unknown history delete range") | ||
| 178 | } | 211 | } | ||
| 212 | + | ||||
| 213 | + | func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) { | |||
| 214 | + | ctx := testContext() | |||
| 215 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 216 | + | want := []*entity.HistoryEntry{{URL: "https://example_.com"}} | |||
| 217 | + | historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once() | |||
| 218 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 219 | + | ||||
| 220 | + | result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0) | |||
| 221 | + | ||||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | assert.Equal(t, want, result) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) { | |||
| 227 | + | ctx := testContext() | |||
| 228 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 229 | + | historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once() | |||
| 230 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 231 | + | ||||
| 232 | + | err := uc.DeleteByDomain(ctx, "example_.com") | |||
| 233 | + | ||||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | } | |||
M internal/domain/history/delete_range.go +1 -1
| @@ -10,7 +10,7 @@ func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) { | |||||
| 10 | case "hour": | 10 | case "hour": | ||
| 11 | return now.Add(-time.Hour), false, true | 11 | return now.Add(-time.Hour), false, true | ||
| 12 | case "day": | 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 | case "week": | 14 | case "week": | ||
| 15 | return now.AddDate(0, 0, -7), false, true | 15 | return now.AddDate(0, 0, -7), false, true | ||
| 16 | case "month": | 16 | case "month": | ||
M internal/domain/history/delete_range_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestDeleteRangeCutoff(t *testing.T) { | |||||
| 16 | wantOK bool | 16 | wantOK bool | ||
| 17 | }{ | 17 | }{ | ||
| 18 | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true}, | 18 | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true}, | ||
| 19 | - | {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true}, | 19 | + | {name: "day", rangeID: "day", want: time.Date(2026, time.April, 11, 0, 0, 0, 0, time.UTC), wantAll: false, wantOK: true}, |
| 20 | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true}, | 20 | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true}, | ||
| 21 | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true}, | 21 | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true}, | ||
| 22 | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true}, | 22 | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true}, | ||
M internal/domain/repository/history.go +3 -0
| @@ -47,6 +47,9 @@ type HistoryRepository interface { | |||||
| 47 | // DeleteOlderThan removes entries older than the given time. | 47 | // DeleteOlderThan removes entries older than the given time. | ||
| 48 | DeleteOlderThan(ctx context.Context, before time.Time) error | 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 | // DeleteAll removes all history entries. | 53 | // DeleteAll removes all history entries. | ||
| 51 | DeleteAll(ctx context.Context) error | 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 | return _c | 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 | // FindByURL provides a mock function for the type MockHistoryRepository | 2189 | // FindByURL provides a mock function for the type MockHistoryRepository | ||
| 2133 | func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | 2190 | func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | ||
| 2134 | ret := _mock.Called(ctx, url) | 2191 | ret := _mock.Called(ctx, url) | ||
M internal/domain/url/normalize.go +2 -4
| @@ -192,10 +192,8 @@ func CanonicalDomain(raw string) string { | |||||
| 192 | if domain := ExtractDomain(raw); domain != "" { | 192 | if domain := ExtractDomain(raw); domain != "" { | ||
| 193 | return domain | 193 | return domain | ||
| 194 | } | 194 | } | ||
| 195 | - | for _, sep := range []string{"/", "?", "#"} { | 195 | + | if idx := strings.IndexAny(raw, "/?#"); idx >= 0 { |
| 196 | - | if before, _, ok := strings.Cut(raw, sep); ok { | 196 | + | raw = raw[:idx] |
| 197 | - | raw = before | |||
| 198 | - | } | |||
| 199 | } | 197 | } | ||
| 200 | return strings.TrimPrefix(raw, "www.") | 198 | return strings.TrimPrefix(raw, "www.") | ||
| 201 | } | 199 | } | ||
M internal/domain/url/normalize_test.go +28 -2
| @@ -699,8 +699,12 @@ func TestCanonicalDomain(t *testing.T) { | |||||
| 699 | input string | 699 | input string | ||
| 700 | want string | 700 | want string | ||
| 701 | }{ | 701 | }{ | ||
| 702 | + | {name: "empty", input: "", want: ""}, | |||
| 703 | + | {name: "whitespace only", input: " ", want: ""}, | |||
| 702 | {name: "url", input: "https://www.example.com/path", want: "example.com"}, | 704 | {name: "url", input: "https://www.example.com/path", want: "example.com"}, | ||
| 703 | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"}, | 705 | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"}, | ||
| 706 | + | {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"}, | |||
| 707 | + | {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"}, | |||
| 704 | {name: "domain", input: "www.example.com", want: "example.com"}, | 708 | {name: "domain", input: "www.example.com", want: "example.com"}, | ||
| 705 | {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"}, | 709 | {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"}, | ||
| 706 | } | 710 | } | ||
| @@ -715,7 +719,29 @@ func TestCanonicalDomain(t *testing.T) { | |||||
| 715 | } | 719 | } | ||
| 716 | 720 | ||||
| 717 | func TestDisplayDomain(t *testing.T) { | 721 | func TestDisplayDomain(t *testing.T) { | ||
| 718 | - | if got := DisplayDomain("https://www.example.com:8443/path"); got != "example.com" { | 722 | + | tests := []struct { |
| 719 | - | t.Errorf("DisplayDomain() = %q, want %q", got, "example.com") | 723 | + | name string |
| 724 | + | input string | |||
| 725 | + | want string | |||
| 726 | + | }{ | |||
| 727 | + | {name: "empty", input: "", want: ""}, | |||
| 728 | + | {name: "whitespace only", input: " ", want: ""}, | |||
| 729 | + | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"}, | |||
| 730 | + | {name: "url without port", input: "https://www.example.com/path", want: "example.com"}, | |||
| 731 | + | {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"}, | |||
| 732 | + | {name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"}, | |||
| 733 | + | {name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"}, | |||
| 734 | + | {name: "invalid url", input: "%zz", want: "%zz"}, | |||
| 735 | + | {name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"}, | |||
| 736 | + | {name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"}, | |||
| 737 | + | {name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"}, | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | for _, tt := range tests { | |||
| 741 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 742 | + | if got := DisplayDomain(tt.input); got != tt.want { | |||
| 743 | + | t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want) | |||
| 744 | + | } | |||
| 745 | + | }) | |||
| 720 | } | 746 | } | ||
| 721 | } | 747 | } | ||
M internal/infrastructure/cef/scheme_handler.go +101 -7
| @@ -1,24 +1,29 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "embed" | 6 | "embed" | ||
| 6 | "encoding/base64" | 7 | "encoding/base64" | ||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "html" | 10 | "html" | ||
| 11 | + | "io" | |||
| 10 | "io/fs" | 12 | "io/fs" | ||
| 13 | + | "net" | |||
| 11 | "net/http" | 14 | "net/http" | ||
| 12 | "net/url" | 15 | "net/url" | ||
| 13 | - | "path/filepath" | 16 | + | "path" |
| 14 | "strings" | 17 | "strings" | ||
| 15 | "sync" | 18 | "sync" | ||
| 16 | "time" | 19 | "time" | ||
| 17 | "unsafe" | 20 | "unsafe" | ||
| 18 | 21 | ||||
| 22 | + | "github.com/andybalholm/brotli" | |||
| 19 | purecef "github.com/bnema/purego-cef/cef" | 23 | purecef "github.com/bnema/purego-cef/cef" | ||
| 20 | 24 | ||||
| 21 | "github.com/bnema/dumber/internal/application/port" | 25 | "github.com/bnema/dumber/internal/application/port" | ||
| 26 | + | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 22 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 23 | "github.com/bnema/dumber/internal/logging" | 28 | "github.com/bnema/dumber/internal/logging" | ||
| 24 | "github.com/rs/zerolog" | 29 | "github.com/rs/zerolog" | ||
| @@ -32,6 +37,8 @@ const ( | |||||
| 32 | errorPath = "error" | 37 | errorPath = "error" | ||
| 33 | indexHTML = "index.html" | 38 | indexHTML = "index.html" | ||
| 34 | maxSchemeTruncatedURLLength = 240 | 39 | maxSchemeTruncatedURLLength = 240 | ||
| 40 | + | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | |||
| 41 | + | systemviewsAssetDir = "systemviews" | |||
| 35 | ) | 42 | ) | ||
| 36 | 43 | ||||
| 37 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | 44 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | ||
| @@ -100,7 +107,7 @@ func newDumbSchemeHandler( | |||||
| 100 | ctx: ctx, | 107 | ctx: ctx, | ||
| 101 | messageRouter: router, | 108 | messageRouter: router, | ||
| 102 | transcoder: transcoder, | 109 | transcoder: transcoder, | ||
| 103 | - | assetDir: "systemviews", | 110 | + | assetDir: systemviewsAssetDir, |
| 104 | logger: log.With().Str("component", "scheme-handler").Logger(), | 111 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 105 | currentConfigPayload: currentConfigPayload, | 112 | currentConfigPayload: currentConfigPayload, | ||
| 106 | defaultConfigPayload: defaultConfigPayload, | 113 | defaultConfigPayload: defaultConfigPayload, | ||
| @@ -190,8 +197,14 @@ func isAPIPostMethod(method string) bool { | |||||
| 190 | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | 197 | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | ||
| 191 | switch path { | 198 | switch path { | ||
| 192 | case "/api/config": | 199 | case "/api/config": | ||
| 200 | + | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |||
| 201 | + | return denied, true | |||
| 202 | + | } | |||
| 193 | return h.handleConfigAPI(h.currentConfigPayload), true | 203 | return h.handleConfigAPI(h.currentConfigPayload), true | ||
| 194 | case "/api/config/default": | 204 | case "/api/config/default": | ||
| 205 | + | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |||
| 206 | + | return denied, true | |||
| 207 | + | } | |||
| 195 | return h.handleConfigAPI(h.defaultConfigPayload), true | 208 | return h.handleConfigAPI(h.defaultConfigPayload), true | ||
| 196 | case "/api/transcode": | 209 | case "/api/transcode": | ||
| 197 | return h.handleTranscodeAPI(request), true | 210 | return h.handleTranscodeAPI(request), true | ||
| @@ -224,6 +237,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, | |||||
| 224 | } | 237 | } | ||
| 225 | 238 | ||||
| 226 | func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler { | 239 | func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 240 | + | if denied := h.rejectForbiddenAPIOrigin(request); denied != nil { | |||
| 241 | + | return denied | |||
| 242 | + | } | |||
| 227 | body := readBodyFromHeader(request) | 243 | body := readBodyFromHeader(request) | ||
| 228 | if body == nil { | 244 | if body == nil { | ||
| 229 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | 245 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | ||
| @@ -235,6 +251,14 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re | |||||
| 235 | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | 251 | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | ||
| 236 | } | 252 | } | ||
| 237 | 253 | ||||
| 254 | + | func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler { | |||
| 255 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |||
| 256 | + | if origin == "" || strings.EqualFold(origin, actualInternalOrigin) { | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"}) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 238 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | 262 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 239 | if h.transcoder == nil { | 263 | if h.transcoder == nil { | ||
| 240 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | 264 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | ||
| @@ -255,6 +279,9 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 255 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | 279 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | ||
| 256 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | 280 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | ||
| 257 | } | 281 | } | ||
| 282 | + | if err := validateTranscodeSourceURL(h.ctx, sourceParsed); err != nil { | |||
| 283 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": err.Error()}) | |||
| 284 | + | } | |||
| 258 | 285 | ||||
| 259 | headers := make(map[string]string) | 286 | headers := make(map[string]string) | ||
| 260 | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | 287 | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | ||
| @@ -287,6 +314,39 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 287 | }) | 314 | }) | ||
| 288 | } | 315 | } | ||
| 289 | 316 | ||||
| 317 | + | func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error { | |||
| 318 | + | if source == nil || source.Host == "" { | |||
| 319 | + | return fmt.Errorf("invalid src") | |||
| 320 | + | } | |||
| 321 | + | host := source.Hostname() | |||
| 322 | + | if host == "" { | |||
| 323 | + | return fmt.Errorf("invalid src host") | |||
| 324 | + | } | |||
| 325 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 326 | + | if isBlockedTranscodeIP(ip) { | |||
| 327 | + | return fmt.Errorf("private src host not allowed") | |||
| 328 | + | } | |||
| 329 | + | return nil | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 333 | + | defer cancel() | |||
| 334 | + | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 335 | + | if err != nil || len(addrs) == 0 { | |||
| 336 | + | return fmt.Errorf("src host could not be resolved") | |||
| 337 | + | } | |||
| 338 | + | for _, addr := range addrs { | |||
| 339 | + | if isBlockedTranscodeIP(addr.IP) { | |||
| 340 | + | return fmt.Errorf("private src host not allowed") | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | return nil | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 347 | + | return netguard.IsBlockedTranscodeIP(ip) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 290 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | 350 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | ||
| 291 | if build == nil { | 351 | if build == nil { | ||
| 292 | return nil, fmt.Errorf("config payload builder not configured") | 352 | return nil, fmt.Errorf("config payload builder not configured") | ||
| @@ -499,7 +559,11 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||||
| 499 | assetDir = h.assetDir | 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 | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | 567 | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | ||
| 504 | if err != nil { | 568 | if err != nil { | ||
| 505 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | 569 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | ||
| @@ -516,10 +580,40 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||||
| 516 | return newStaticResourceHandler(http.StatusOK, contentType, data, headers) | 580 | return newStaticResourceHandler(http.StatusOK, contentType, data, headers) | ||
| 517 | } | 581 | } | ||
| 518 | 582 | ||||
| 583 | + | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |||
| 584 | + | assetDir = strings.Trim(assetDir, "/") | |||
| 585 | + | if assetDir != systemviewsAssetDir { | |||
| 586 | + | return "", "", false | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | relPath = strings.TrimLeft(relPath, "/") | |||
| 590 | + | if relPath == "" || strings.ContainsRune(relPath, '\x00') { | |||
| 591 | + | return "", "", false | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | cleanRelPath = path.Clean(relPath) | |||
| 595 | + | if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) { | |||
| 596 | + | return "", "", false | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | fullPath = path.Join(assetDir, cleanRelPath) | |||
| 600 | + | if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") { | |||
| 601 | + | return "", "", false | |||
| 602 | + | } | |||
| 603 | + | return fullPath, cleanRelPath, true | |||
| 604 | + | } | |||
| 605 | + | ||||
| 519 | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | 606 | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | ||
| 520 | if strings.HasSuffix(relPath, ".wasm") { | 607 | if strings.HasSuffix(relPath, ".wasm") { | ||
| 521 | - | if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | 608 | + | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { |
| 522 | - | return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, 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 | data, err := fs.ReadFile(assets, fullPath) | 619 | data, err := fs.ReadFile(assets, fullPath) | ||
| @@ -591,7 +685,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 591 | } | 685 | } | ||
| 592 | 686 | ||||
| 593 | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | 687 | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | ||
| 594 | - | return "systemviews", path, true | 688 | + | return systemviewsAssetDir, path, true |
| 595 | } | 689 | } | ||
| 596 | 690 | ||||
| 597 | return "", "", false | 691 | return "", "", false | ||
| @@ -600,7 +694,7 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 600 | func assetDirForPageHost(host string) string { | 694 | func assetDirForPageHost(host string) string { | ||
| 601 | switch host { | 695 | switch host { | ||
| 602 | case historyPath, favoritesPath, configPath, errorPath: | 696 | case historyPath, favoritesPath, configPath, errorPath: | ||
| 603 | - | return "systemviews" | 697 | + | return systemviewsAssetDir |
| 604 | default: | 698 | default: | ||
| 605 | return "" | 699 | return "" | ||
| 606 | } | 700 | } | ||
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +49 -0
| @@ -3,6 +3,8 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "net/url" | 4 | "net/url" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/assets" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | 10 | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | ||
| @@ -40,6 +42,21 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||||
| 40 | } | 42 | } | ||
| 41 | } | 43 | } | ||
| 42 | 44 | ||||
| 45 | + | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | |||
| 46 | + | t.Parallel() | |||
| 47 | + | ||||
| 48 | + | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatalf("readAssetWithEncoding() error = %v", err) | |||
| 51 | + | } | |||
| 52 | + | if len(data) < 4 || string(data[:4]) != "\x00asm" { | |||
| 53 | + | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | |||
| 54 | + | } | |||
| 55 | + | if _, ok := headers["Content-Encoding"]; ok { | |||
| 56 | + | t.Fatalf("Content-Encoding header = %q, want absent after server-side decompression", headers["Content-Encoding"]) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 43 | func mustParseURL(t *testing.T, raw string) *url.URL { | 60 | func mustParseURL(t *testing.T, raw string) *url.URL { | ||
| 44 | t.Helper() | 61 | t.Helper() | ||
| 45 | u, err := url.Parse(raw) | 62 | u, err := url.Parse(raw) | ||
| @@ -48,3 +65,35 @@ func mustParseURL(t *testing.T, raw string) *url.URL { | |||||
| 48 | } | 65 | } | ||
| 49 | return u | 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 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "net" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 9 | + | "net/url" | |||
| 8 | "testing" | 10 | "testing" | ||
| 9 | 11 | ||||
| 10 | purecef "github.com/bnema/purego-cef/cef" | 12 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -60,6 +62,57 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||||
| 60 | require.Contains(t, err.Error(), "default config payload builder") | 62 | require.Contains(t, err.Error(), "default config payload builder") | ||
| 61 | } | 63 | } | ||
| 62 | 64 | ||||
| 65 | + | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | |||
| 66 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | ||||
| 69 | + | request := cefmocks.NewMockRequest(t) | |||
| 70 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 71 | + | ||||
| 72 | + | require.Nil(t, h.rejectForbiddenAPIOrigin(request)) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||
| 76 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 77 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 78 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 79 | + | ||||
| 80 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | ||||
| 83 | + | request := cefmocks.NewMockRequest(t) | |||
| 84 | + | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |||
| 85 | + | ||||
| 86 | + | response := cefmocks.NewMockResponse(t) | |||
| 87 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 88 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 89 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 90 | + | expectAPIResponseHeaders(response) | |||
| 91 | + | ||||
| 92 | + | handler := h.rejectForbiddenAPIOrigin(request) | |||
| 93 | + | require.NotNil(t, handler) | |||
| 94 | + | var responseLength int64 | |||
| 95 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |||
| 96 | + | require.Positive(t, responseLength) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |||
| 100 | + | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | ||||
| 103 | + | err = validateTranscodeSourceURL(context.Background(), privateURL) | |||
| 104 | + | require.Error(t, err) | |||
| 105 | + | require.Contains(t, err.Error(), "private src host") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 109 | + | for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} { | |||
| 110 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 111 | + | require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP))) | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 63 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | 116 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | ||
| 64 | request := cefmocks.NewMockRequest(t) | 117 | request := cefmocks.NewMockRequest(t) | ||
| 65 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | 118 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | ||
M internal/infrastructure/config/defaults.go +11 -11
| @@ -238,17 +238,17 @@ func DefaultConfig() *Config { | |||||
| 238 | }, | 238 | }, | ||
| 239 | Shortcuts: GlobalShortcutsConfig{ | 239 | Shortcuts: GlobalShortcutsConfig{ | ||
| 240 | Actions: map[string]ActionBinding{ | 240 | Actions: map[string]ActionBinding{ | ||
| 241 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | 241 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, |
| 242 | - | "toggle_history_systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, | 242 | + | "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, |
| 243 | - | "toggle_favorites_systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"}, | 243 | + | "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"}, |
| 244 | - | "toggle_config_systemview": {Keys: []string{}, Desc: "Toggle Config in right split"}, | 244 | + | "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"}, |
| 245 | - | "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, | 245 | + | "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, |
| 246 | - | "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, | 246 | + | "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, |
| 247 | - | "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | 247 | + | "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, |
| 248 | - | "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, | 248 | + | "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, |
| 249 | - | "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | 249 | + | "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, |
| 250 | - | "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | 250 | + | "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, |
| 251 | - | "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | 251 | + | "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, |
| 252 | }, | 252 | }, | ||
| 253 | }, | 253 | }, | ||
| 254 | FloatingPane: FloatingPaneConfig{ | 254 | FloatingPane: FloatingPaneConfig{ | ||
M internal/infrastructure/config/defaults_test.go +3 -3
| @@ -28,9 +28,9 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | ||
| 29 | 29 | ||||
| 30 | // System view shortcuts are first-class global actions. | 30 | // System view shortcuts are first-class global actions. | ||
| 31 | - | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_history_systemview", []string{"ctrl+h"}) | 31 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"}) |
| 32 | - | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_favorites_systemview", []string{}) | 32 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{}) |
| 33 | - | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle_config_systemview", []string{}) | 33 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{}) |
| 34 | 34 | ||||
| 35 | // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config. | 35 | // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config. | ||
| 36 | // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above). | 36 | // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above). | ||
M internal/infrastructure/config/floating_pane_config_test.go +2 -2
| @@ -15,7 +15,7 @@ func TestFloatingPaneConfig_Defaults(t *testing.T) { | |||||
| 15 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | 15 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | ||
| 16 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | 16 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | ||
| 17 | 17 | ||||
| 18 | - | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"] | 18 | + | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"] |
| 19 | require.True(t, ok) | 19 | require.True(t, ok) | ||
| 20 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | 20 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | ||
| 21 | } | 21 | } | ||
| @@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) { | |||||
| 31 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | 31 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | ||
| 32 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | 32 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | ||
| 33 | 33 | ||||
| 34 | - | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"] | 34 | + | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"] |
| 35 | require.True(t, ok) | 35 | require.True(t, ok) | ||
| 36 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | 36 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | ||
| 37 | } | 37 | } | ||
M internal/infrastructure/config/migrate_test.go +4 -4
| @@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) { | |||||
| 695 | for actionName, value := range defaultActions { | 695 | for actionName, value := range defaultActions { | ||
| 696 | userActions[actionName] = value | 696 | userActions[actionName] = value | ||
| 697 | } | 697 | } | ||
| 698 | - | delete(userActions, "toggle_floating_pane") | 698 | + | delete(userActions, "toggle-floating-pane") |
| 699 | 699 | ||||
| 700 | missing := m.detectMissingWorkspaceShortcutActions(map[string]any{ | 700 | missing := m.detectMissingWorkspaceShortcutActions(map[string]any{ | ||
| 701 | "workspace.shortcuts.actions": userActions, | 701 | "workspace.shortcuts.actions": userActions, | ||
| 702 | }) | 702 | }) | ||
| 703 | 703 | ||||
| 704 | - | assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing) | 704 | + | assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing) |
| 705 | } | 705 | } | ||
| 706 | 706 | ||||
| 707 | func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | 707 | func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | ||
| @@ -750,7 +750,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |||||
| 750 | actions, ok := actionsAny.(map[string]any) | 750 | actions, ok := actionsAny.(map[string]any) | ||
| 751 | require.True(t, ok) | 751 | require.True(t, ok) | ||
| 752 | 752 | ||||
| 753 | - | toggleAny, hasToggle := actions["toggle_floating_pane"] | 753 | + | toggleAny, hasToggle := actions["toggle-floating-pane"] |
| 754 | assert.True(t, hasToggle) | 754 | assert.True(t, hasToggle) | ||
| 755 | assert.NotNil(t, toggleAny) | 755 | assert.NotNil(t, toggleAny) | ||
| 756 | 756 | ||||
| @@ -764,7 +764,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |||||
| 764 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | 764 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | ||
| 765 | m := NewMigrator() | 765 | m := NewMigrator() | ||
| 766 | 766 | ||||
| 767 | - | value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane") | 767 | + | value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane") |
| 768 | 768 | ||||
| 769 | action, ok := value.(ActionBinding) | 769 | action, ok := value.(ActionBinding) | ||
| 770 | require.True(t, ok) | 770 | require.True(t, ok) | ||
M internal/infrastructure/config/webui_payload.go +22 -18
| @@ -45,31 +45,35 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste | |||||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig { | 47 | func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig { | ||
| 48 | + | defaults := DefaultConfig().Appearance | |||
| 48 | return port.WebUIAppearanceConfig{ | 49 | return port.WebUIAppearanceConfig{ | ||
| 49 | SansFont: appearance.SansFont, | 50 | SansFont: appearance.SansFont, | ||
| 50 | SerifFont: appearance.SerifFont, | 51 | SerifFont: appearance.SerifFont, | ||
| 51 | MonospaceFont: appearance.MonospaceFont, | 52 | MonospaceFont: appearance.MonospaceFont, | ||
| 52 | DefaultFontSize: appearance.DefaultFontSize, | 53 | DefaultFontSize: appearance.DefaultFontSize, | ||
| 53 | ColorScheme: appearance.ColorScheme, | 54 | ColorScheme: appearance.ColorScheme, | ||
| 54 | - | LightPalette: port.ColorPalette{ | 55 | + | LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette), |
| 55 | - | Background: appearance.LightPalette.Background, | 56 | + | DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette), |
| 56 | - | Surface: appearance.LightPalette.Surface, | 57 | + | } |
| 57 | - | SurfaceVariant: appearance.LightPalette.SurfaceVariant, | 58 | + | } |
| 58 | - | Text: appearance.LightPalette.Text, | 59 | + | |
| 59 | - | Muted: appearance.LightPalette.Muted, | 60 | + | func buildSystemviewPalettePayload(palette, fallback ColorPalette) port.ColorPalette { |
| 60 | - | Accent: appearance.LightPalette.Accent, | 61 | + | return port.ColorPalette{ |
| 61 | - | Border: appearance.LightPalette.Border, | 62 | + | Background: nonEmptyColor(palette.Background, fallback.Background), |
| 62 | - | }, | 63 | + | Surface: nonEmptyColor(palette.Surface, fallback.Surface), |
| 63 | - | DarkPalette: port.ColorPalette{ | 64 | + | SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant), |
| 64 | - | Background: appearance.DarkPalette.Background, | 65 | + | Text: nonEmptyColor(palette.Text, fallback.Text), |
| 65 | - | Surface: appearance.DarkPalette.Surface, | 66 | + | Muted: nonEmptyColor(palette.Muted, fallback.Muted), |
| 66 | - | SurfaceVariant: appearance.DarkPalette.SurfaceVariant, | 67 | + | Accent: nonEmptyColor(palette.Accent, fallback.Accent), |
| 67 | - | Text: appearance.DarkPalette.Text, | 68 | + | Border: nonEmptyColor(palette.Border, fallback.Border), |
| 68 | - | Muted: appearance.DarkPalette.Muted, | 69 | + | } |
| 69 | - | Accent: appearance.DarkPalette.Accent, | 70 | + | } |
| 70 | - | Border: appearance.DarkPalette.Border, | 71 | + | |
| 71 | - | }, | 72 | + | func nonEmptyColor(value, fallback string) string { |
| 73 | + | if value != "" { | |||
| 74 | + | return value | |||
| 72 | } | 75 | } | ||
| 76 | + | return fallback | |||
| 73 | } | 77 | } | ||
| 74 | 78 | ||||
| 75 | func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut { | 79 | func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut { | ||
M internal/infrastructure/config/webui_payload_test.go +12 -0
| @@ -13,3 +13,15 @@ func TestBuildSystemviewConfigPayload_EmptySearchShortcutsUsesEmptyMap(t *testin | |||||
| 13 | require.NotNil(t, payload.SearchShortcuts) | 13 | require.NotNil(t, payload.SearchShortcuts) | ||
| 14 | require.Empty(t, payload.SearchShortcuts) | 14 | require.Empty(t, payload.SearchShortcuts) | ||
| 15 | } | 15 | } | ||
| 16 | + | ||||
| 17 | + | func TestBuildSystemviewConfigPayload_FillsMissingPaletteValuesFromDefaults(t *testing.T) { | |||
| 18 | + | cfg := DefaultConfig() | |||
| 19 | + | cfg.Appearance.LightPalette.Background = "" | |||
| 20 | + | cfg.Appearance.DarkPalette.Accent = "" | |||
| 21 | + | ||||
| 22 | + | payload := BuildSystemviewConfigPayload(cfg, nil) | |||
| 23 | + | defaults := DefaultConfig() | |||
| 24 | + | ||||
| 25 | + | require.Equal(t, defaults.Appearance.LightPalette.Background, payload.Appearance.LightPalette.Background) | |||
| 26 | + | require.Equal(t, defaults.Appearance.DarkPalette.Accent, payload.Appearance.DarkPalette.Accent) | |||
| 27 | + | } | |||
M internal/infrastructure/handlers/config.go +6 -2
| @@ -24,7 +24,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) | |||||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | // Handle processes the save_config message. | 26 | // Handle processes the save_config message. | ||
| 27 | - | func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 27 | + | func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, payload json.RawMessage) (any, error) { |
| 28 | if h == nil { | 28 | if h == nil { | ||
| 29 | return nil, fmt.Errorf("config handler is nil") | 29 | return nil, fmt.Errorf("config handler is nil") | ||
| 30 | } | 30 | } | ||
| @@ -39,7 +39,11 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js | |||||
| 39 | return nil, fmt.Errorf("invalid config format: %w", err) | 39 | return nil, fmt.Errorf("invalid config format: %w", err) | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | - | log.Info().Msg("saving appearance configuration") | 42 | + | source := "webui" |
| 43 | + | if viewID != 0 { | |||
| 44 | + | source = fmt.Sprintf("webview:%d", viewID) | |||
| 45 | + | } | |||
| 46 | + | log.Info().Str("source", source).Msg("saving appearance configuration") | |||
| 43 | 47 | ||||
| 44 | if err := h.saveConfig(ctx, payloadCfg); err != nil { | 48 | if err := h.saveConfig(ctx, payloadCfg); err != nil { | ||
| 45 | log.Error().Err(err).Msg("failed to save config") | 49 | log.Error().Err(err).Msg("failed to save config") | ||
M internal/infrastructure/handlers/homepage/favorites.go +33 -25
| @@ -3,12 +3,32 @@ package homepage | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 14 | + | func folderIDFromInt64(id *int64) *entity.FolderID { | |||
| 15 | + | if id == nil { | |||
| 16 | + | return nil | |||
| 17 | + | } | |||
| 18 | + | folderID := entity.FolderID(*id) | |||
| 19 | + | return &folderID | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func tagIDsFromInt64s(ids []int64) []entity.TagID { | |||
| 23 | + | out := make([]entity.TagID, 0, len(ids)) | |||
| 24 | + | for _, id := range ids { | |||
| 25 | + | if id > 0 { | |||
| 26 | + | out = append(out, entity.TagID(id)) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | return out | |||
| 30 | + | } | |||
| 31 | + | ||||
| 12 | // FavoritesHandlers handles favorite-related messages from the homepage. | 32 | // FavoritesHandlers handles favorite-related messages from the homepage. | ||
| 13 | type FavoritesHandlers struct { | 33 | type FavoritesHandlers struct { | ||
| 14 | favoritesUC port.HomepageFavorites | 34 | favoritesUC port.HomepageFavorites | ||
| @@ -63,6 +83,10 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |||||
| 63 | Str("url", req.URL). | 83 | Str("url", req.URL). | ||
| 64 | Msg("handling favorite_create") | 84 | Msg("handling favorite_create") | ||
| 65 | 85 | ||||
| 86 | + | if strings.TrimSpace(req.URL) == "" { | |||
| 87 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 66 | input := port.FavoriteCreateInput{ | 90 | input := port.FavoriteCreateInput{ | ||
| 67 | URL: req.URL, | 91 | URL: req.URL, | ||
| 68 | Title: req.Title, | 92 | Title: req.Title, | ||
| @@ -103,6 +127,10 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |||||
| 103 | Int64("favorite_id", req.ID). | 127 | Int64("favorite_id", req.ID). | ||
| 104 | Msg("handling favorite_update") | 128 | Msg("handling favorite_update") | ||
| 105 | 129 | ||||
| 130 | + | if req.ID <= 0 { | |||
| 131 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | |||
| 132 | + | } | |||
| 133 | + | ||||
| 106 | favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | 134 | favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{ | ||
| 107 | ID: entity.FavoriteID(req.ID), | 135 | ID: entity.FavoriteID(req.ID), | ||
| 108 | Title: req.Title, | 136 | Title: req.Title, | ||
| @@ -138,6 +166,10 @@ func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler { | |||||
| 138 | Int64("favorite_id", req.ID). | 166 | Int64("favorite_id", req.ID). | ||
| 139 | Msg("handling favorite_delete") | 167 | Msg("handling favorite_delete") | ||
| 140 | 168 | ||||
| 169 | + | if req.ID <= 0 { | |||
| 170 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | |||
| 171 | + | } | |||
| 172 | + | ||||
| 141 | if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil { | 173 | if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil { | ||
| 142 | return NewErrorResponse(req.RequestID, err), nil | 174 | return NewErrorResponse(req.RequestID, err), nil | ||
| 143 | } | 175 | } | ||
| @@ -214,24 +246,6 @@ type setFolderRequest struct { | |||||
| 214 | } | 246 | } | ||
| 215 | 247 | ||||
| 216 | // HandleSetFolder handles favorite_set_folder messages. | 248 | // HandleSetFolder handles favorite_set_folder messages. | ||
| 217 | - | func folderIDFromInt64(id *int64) *entity.FolderID { | |||
| 218 | - | if id == nil { | |||
| 219 | - | return nil | |||
| 220 | - | } | |||
| 221 | - | folderID := entity.FolderID(*id) | |||
| 222 | - | return &folderID | |||
| 223 | - | } | |||
| 224 | - | ||||
| 225 | - | func tagIDsFromInt64s(ids []int64) []entity.TagID { | |||
| 226 | - | out := make([]entity.TagID, 0, len(ids)) | |||
| 227 | - | for _, id := range ids { | |||
| 228 | - | if id > 0 { | |||
| 229 | - | out = append(out, entity.TagID(id)) | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | - | return out | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | 249 | func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | ||
| 236 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 250 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
| 237 | log := logging.FromContext(ctx) | 251 | log := logging.FromContext(ctx) | ||
| @@ -246,13 +260,7 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |||||
| 246 | Int64("favorite_id", req.FavoriteID). | 260 | Int64("favorite_id", req.FavoriteID). | ||
| 247 | Msg("handling favorite_set_folder") | 261 | Msg("handling favorite_set_folder") | ||
| 248 | 262 | ||||
| 249 | - | var folderID *entity.FolderID | 263 | + | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderIDFromInt64(req.FolderID)); err != nil { |
| 250 | - | if req.FolderID != nil { | |||
| 251 | - | id := entity.FolderID(*req.FolderID) | |||
| 252 | - | folderID = &id | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil { | |||
| 256 | return NewErrorResponse(req.RequestID, err), nil | 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 | RequestID string `json:"requestId"` | 44 | RequestID string `json:"requestId"` | ||
| 45 | Name string `json:"name"` | 45 | Name string `json:"name"` | ||
| 46 | Icon *string `json:"icon"` | 46 | Icon *string `json:"icon"` | ||
| 47 | + | ParentID *int64 `json:"parent_id"` | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | // HandleCreate handles folder_create messages. | 50 | // HandleCreate handles folder_create messages. | ||
| @@ -61,19 +62,15 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler { | |||||
| 61 | Str("name", req.Name). | 62 | Str("name", req.Name). | ||
| 62 | Msg("handling folder_create") | 63 | Msg("handling folder_create") | ||
| 63 | 64 | ||||
| 64 | - | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, nil) | 65 | + | icon := "" |
| 66 | + | if req.Icon != nil { | |||
| 67 | + | icon = *req.Icon | |||
| 68 | + | } | |||
| 69 | + | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, icon, folderIDFromInt64(req.ParentID)) | |||
| 65 | if err != nil { | 70 | if err != nil { | ||
| 66 | return NewErrorResponse(req.RequestID, err), nil | 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 | return NewSuccessResponse(req.RequestID, folder), nil | 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 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -68,6 +70,11 @@ func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||||
| 68 | return NewErrorResponse("", err), nil | 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 | log.Debug(). | 78 | log.Debug(). | ||
| 72 | Str("request_id", req.RequestID). | 79 | Str("request_id", req.RequestID). | ||
| 73 | Str("domain", req.Domain). | 80 | Str("domain", req.Domain). | ||
| @@ -169,11 +176,15 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler { | |||||
| 169 | return NewErrorResponse("", err), nil | 176 | return NewErrorResponse("", err), nil | ||
| 170 | } | 177 | } | ||
| 171 | 178 | ||||
| 179 | + | req.Range = strings.TrimSpace(req.Range) | |||
| 172 | log.Debug(). | 180 | log.Debug(). | ||
| 173 | Str("request_id", req.RequestID). | 181 | Str("request_id", req.RequestID). | ||
| 174 | Str("range", req.Range). | 182 | Str("range", req.Range). | ||
| 175 | Msg("handling history_delete_range") | 183 | Msg("handling history_delete_range") | ||
| 176 | 184 | ||||
| 185 | + | if !isAllowedHistoryDeleteRange(req.Range) { | |||
| 186 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid range: %s", req.Range)), nil | |||
| 187 | + | } | |||
| 177 | if err := h.historyUC.ClearRange(ctx, req.Range); err != nil { | 188 | if err := h.historyUC.ClearRange(ctx, req.Range); err != nil { | ||
| 178 | return NewErrorResponse(req.RequestID, err), nil | 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 | // HandleClearAll handles history_clear_all messages. | 205 | // HandleClearAll handles history_clear_all messages. | ||
| 186 | func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | 206 | func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | ||
| 187 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 207 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
A internal/infrastructure/netguard/transcode.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package netguard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/netip" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | var blockedTranscodeIPPfxs = []netip.Prefix{ | |||
| 9 | + | netip.MustParsePrefix("0.0.0.0/8"), | |||
| 10 | + | netip.MustParsePrefix("10.0.0.0/8"), | |||
| 11 | + | netip.MustParsePrefix("100.64.0.0/10"), | |||
| 12 | + | netip.MustParsePrefix("127.0.0.0/8"), | |||
| 13 | + | netip.MustParsePrefix("169.254.0.0/16"), | |||
| 14 | + | netip.MustParsePrefix("172.16.0.0/12"), | |||
| 15 | + | netip.MustParsePrefix("192.0.0.0/24"), | |||
| 16 | + | netip.MustParsePrefix("192.0.2.0/24"), | |||
| 17 | + | netip.MustParsePrefix("192.168.0.0/16"), | |||
| 18 | + | netip.MustParsePrefix("198.18.0.0/15"), | |||
| 19 | + | netip.MustParsePrefix("198.51.100.0/24"), | |||
| 20 | + | netip.MustParsePrefix("203.0.113.0/24"), | |||
| 21 | + | netip.MustParsePrefix("224.0.0.0/4"), | |||
| 22 | + | netip.MustParsePrefix("240.0.0.0/4"), | |||
| 23 | + | netip.MustParsePrefix("::/128"), | |||
| 24 | + | netip.MustParsePrefix("::1/128"), | |||
| 25 | + | netip.MustParsePrefix("64:ff9b::/96"), | |||
| 26 | + | netip.MustParsePrefix("64:ff9b:1::/48"), | |||
| 27 | + | netip.MustParsePrefix("100::/64"), | |||
| 28 | + | netip.MustParsePrefix("2001::/32"), | |||
| 29 | + | netip.MustParsePrefix("2001:2::/48"), | |||
| 30 | + | netip.MustParsePrefix("2001:db8::/32"), | |||
| 31 | + | netip.MustParsePrefix("2002::/16"), | |||
| 32 | + | netip.MustParsePrefix("fc00::/7"), | |||
| 33 | + | netip.MustParsePrefix("fe80::/10"), | |||
| 34 | + | netip.MustParsePrefix("ff00::/8"), | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // IsBlockedTranscodeIP reports whether ip belongs to a local, private, | |||
| 38 | + | // documentation, transition, or otherwise special-purpose range that a media | |||
| 39 | + | // transcoder must not fetch on behalf of web content. | |||
| 40 | + | func IsBlockedTranscodeIP(ip net.IP) bool { | |||
| 41 | + | addr, ok := netip.AddrFromSlice(ip) | |||
| 42 | + | if !ok { | |||
| 43 | + | return true | |||
| 44 | + | } | |||
| 45 | + | addr = addr.Unmap() | |||
| 46 | + | for _, prefix := range blockedTranscodeIPPfxs { | |||
| 47 | + | if prefix.Contains(addr) { | |||
| 48 | + | return true | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | return false | |||
| 52 | + | } | |||
A internal/infrastructure/netguard/transcode_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package netguard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) { | |||
| 9 | + | tests := []string{ | |||
| 10 | + | "127.0.0.1", | |||
| 11 | + | "10.0.0.1", | |||
| 12 | + | "172.16.0.1", | |||
| 13 | + | "192.168.1.1", | |||
| 14 | + | "100.64.0.1", | |||
| 15 | + | "198.18.0.1", | |||
| 16 | + | "192.0.2.1", | |||
| 17 | + | "169.254.169.254", | |||
| 18 | + | "0.0.0.0", | |||
| 19 | + | "64:ff9b::a00:1", | |||
| 20 | + | "2001::1", | |||
| 21 | + | "2001:db8::1", | |||
| 22 | + | "2002:0a00:0001::1", | |||
| 23 | + | "fc00::1", | |||
| 24 | + | "fe80::1", | |||
| 25 | + | "::ffff:127.0.0.1", | |||
| 26 | + | } | |||
| 27 | + | for _, rawIP := range tests { | |||
| 28 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 29 | + | if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 30 | + | t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) { | |||
| 37 | + | if !IsBlockedTranscodeIP(net.ParseIP("")) { | |||
| 38 | + | t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true") | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) { | |||
| 43 | + | for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} { | |||
| 44 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 45 | + | if IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 46 | + | t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP) | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
M internal/infrastructure/persistence/sqlite/favorite_repo.go +6 -8
| @@ -61,6 +61,11 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, | |||||
| 61 | } | 61 | } | ||
| 62 | }() | 62 | }() | ||
| 63 | 63 | ||||
| 64 | + | shortcutKey := sql.NullInt64{} | |||
| 65 | + | if fav.ShortcutKey != nil { | |||
| 66 | + | shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 64 | queries := r.queries.WithTx(tx) | 69 | queries := r.queries.WithTx(tx) | ||
| 65 | if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | 70 | if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | ||
| 66 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | 71 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | ||
| @@ -73,7 +78,7 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, | |||||
| 73 | return err | 78 | return err | ||
| 74 | } | 79 | } | ||
| 75 | if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | 80 | if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | ||
| 76 | - | ShortcutKey: sql.NullInt64{Int64: int64Value(fav.ShortcutKey), Valid: fav.ShortcutKey != nil}, | 81 | + | ShortcutKey: shortcutKey, |
| 77 | ID: int64(fav.ID), | 82 | ID: int64(fav.ID), | ||
| 78 | }); err != nil { | 83 | }); err != nil { | ||
| 79 | return err | 84 | return err | ||
| @@ -81,13 +86,6 @@ func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, | |||||
| 81 | return tx.Commit() | 86 | return tx.Commit() | ||
| 82 | } | 87 | } | ||
| 83 | 88 | ||||
| 84 | - | func int64Value(value *int) int64 { | |||
| 85 | - | if value == nil { | |||
| 86 | - | return 0 | |||
| 87 | - | } | |||
| 88 | - | return int64(*value) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | 89 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | ||
| 92 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | 90 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | ||
| 93 | if err != nil { | 91 | if err != nil { | ||
M internal/infrastructure/persistence/sqlite/history_repo.go +16 -21
| @@ -61,10 +61,12 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro | |||||
| 61 | log := logging.FromContext(ctx) | 61 | log := logging.FromContext(ctx) | ||
| 62 | log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry") | 62 | log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry") | ||
| 63 | 63 | ||||
| 64 | + | domain := domainurl.CanonicalDomain(entry.URL) | |||
| 64 | err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{ | 65 | err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{ | ||
| 65 | Url: entry.URL, | 66 | Url: entry.URL, | ||
| 66 | Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""}, | 67 | Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""}, | ||
| 67 | FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""}, | 68 | FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""}, | ||
| 69 | + | Domain: sql.NullString{String: domain, Valid: domain != ""}, | |||
| 68 | }) | 70 | }) | ||
| 69 | if err != nil { | 71 | if err != nil { | ||
| 70 | return err | 72 | return err | ||
| @@ -518,11 +520,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H | |||||
| 518 | return nil, err | 520 | return nil, err | ||
| 519 | } | 521 | } | ||
| 520 | 522 | ||||
| 521 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 523 | + | return historyEntriesFromRows(rows), nil |
| 522 | - | for i := range rows { | |||
| 523 | - | entries[i] = historyFromRow(rows[i]) | |||
| 524 | - | } | |||
| 525 | - | return entries, nil | |||
| 526 | } | 524 | } | ||
| 527 | 525 | ||||
| 528 | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 526 | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| @@ -536,11 +534,7 @@ func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.H | |||||
| 536 | return nil, err | 534 | return nil, err | ||
| 537 | } | 535 | } | ||
| 538 | 536 | ||||
| 539 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 537 | + | return historyEntriesFromRows(rows), nil |
| 540 | - | for i := range rows { | |||
| 541 | - | entries[i] = historyFromRow(rows[i]) | |||
| 542 | - | } | |||
| 543 | - | return entries, nil | |||
| 544 | } | 538 | } | ||
| 545 | 539 | ||||
| 546 | func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | 540 | func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | ||
| @@ -549,11 +543,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor | |||||
| 549 | return nil, err | 543 | return nil, err | ||
| 550 | } | 544 | } | ||
| 551 | 545 | ||||
| 552 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 546 | + | return historyEntriesFromRows(rows), nil |
| 553 | - | for i := range rows { | |||
| 554 | - | entries[i] = historyFromRow(rows[i]) | |||
| 555 | - | } | |||
| 556 | - | return entries, nil | |||
| 557 | } | 547 | } | ||
| 558 | 548 | ||||
| 559 | func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) { | 549 | func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) { | ||
| @@ -562,11 +552,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||||
| 562 | return nil, err | 552 | return nil, err | ||
| 563 | } | 553 | } | ||
| 564 | 554 | ||||
| 565 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 555 | + | return historyEntriesFromRows(rows), nil |
| 566 | - | for i := range rows { | |||
| 567 | - | entries[i] = historyFromRow(rows[i]) | |||
| 568 | - | } | |||
| 569 | - | return entries, nil | |||
| 570 | } | 556 | } | ||
| 571 | 557 | ||||
| 572 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | 558 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | ||
| @@ -599,13 +585,22 @@ func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) err | |||||
| 599 | return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true}) | 585 | return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true}) | ||
| 600 | } | 586 | } | ||
| 601 | 587 | ||||
| 588 | + | func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error { | |||
| 589 | + | return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true}) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 602 | func (r *historyRepo) DeleteAll(ctx context.Context) error { | 592 | func (r *historyRepo) DeleteAll(ctx context.Context) error { | ||
| 603 | return r.queries.DeleteAllHistory(ctx) | 593 | return r.queries.DeleteAllHistory(ctx) | ||
| 604 | } | 594 | } | ||
| 605 | 595 | ||||
| 606 | func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error { | 596 | func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error { | ||
| 597 | + | rawDomain := domain | |||
| 607 | domain = domainurl.CanonicalDomain(domain) | 598 | domain = domainurl.CanonicalDomain(domain) | ||
| 608 | - | return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: domain != ""}) | 599 | + | if domain == "" { |
| 600 | + | logging.FromContext(ctx).Debug().Str("domain", rawDomain).Msg("skipping delete by domain: empty canonical domain") | |||
| 601 | + | return nil | |||
| 602 | + | } | |||
| 603 | + | return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true}) | |||
| 609 | } | 604 | } | ||
| 610 | 605 | ||||
| 611 | func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | 606 | func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +38 -0
| @@ -832,3 +832,41 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) | |||||
| 832 | require.NoError(t, err) | 832 | require.NoError(t, err) | ||
| 833 | assert.Empty(t, results) | 833 | assert.Empty(t, results) | ||
| 834 | } | 834 | } | ||
| 835 | + | ||||
| 836 | + | func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) { | |||
| 837 | + | ctx := historyTestCtx() | |||
| 838 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 839 | + | ||||
| 840 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 841 | + | require.NoError(t, err) | |||
| 842 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 843 | + | ||||
| 844 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 845 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"})) | |||
| 846 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"})) | |||
| 847 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"})) | |||
| 848 | + | ||||
| 849 | + | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 10, 0) | |||
| 850 | + | require.NoError(t, err) | |||
| 851 | + | require.Len(t, exampleEntries, 2) | |||
| 852 | + | for _, entry := range exampleEntries { | |||
| 853 | + | assert.Contains(t, entry.URL, "example.com") | |||
| 854 | + | } | |||
| 855 | + | ||||
| 856 | + | stats, err := repo.GetDomainStats(ctx, 10) | |||
| 857 | + | require.NoError(t, err) | |||
| 858 | + | require.NotEmpty(t, stats) | |||
| 859 | + | assert.Equal(t, "example.com", stats[0].Domain) | |||
| 860 | + | assert.Equal(t, int64(2), stats[0].PageCount) | |||
| 861 | + | ||||
| 862 | + | require.NoError(t, repo.DeleteByDomain(ctx, "example.com")) | |||
| 863 | + | ||||
| 864 | + | exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0) | |||
| 865 | + | require.NoError(t, err) | |||
| 866 | + | assert.Empty(t, exampleEntries) | |||
| 867 | + | ||||
| 868 | + | remaining, err := repo.GetAllRecentHistory(ctx) | |||
| 869 | + | require.NoError(t, err) | |||
| 870 | + | require.Len(t, remaining, 1) | |||
| 871 | + | assert.Equal(t, "https://other.test", remaining[0].URL) | |||
| 872 | + | } | |||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +7 -0
| @@ -230,6 +230,13 @@ func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time | |||||
| 230 | return r.repo.DeleteOlderThan(ctx, before) | 230 | return r.repo.DeleteOlderThan(ctx, before) | ||
| 231 | } | 231 | } | ||
| 232 | 232 | ||||
| 233 | + | func (r *LazyHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error { | |||
| 234 | + | if err := r.init(ctx); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | return r.repo.DeleteSince(ctx, since) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 233 | func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error { | 240 | func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error { | ||
| 234 | if err := r.init(ctx); err != nil { | 241 | if err := r.init(ctx); err != nil { | ||
| 235 | return err | 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 | -- name: GetRecentHistoryByDomain :many | 4 | -- name: GetRecentHistoryByDomain :many | ||
| 5 | SELECT * FROM history | 5 | SELECT * FROM history | ||
| 6 | - | WHERE url LIKE '%://' || @domain || '/%' | 6 | + | WHERE domain = @domain |
| 7 | - | OR url LIKE '%://' || @domain || '?%' | |||
| 8 | - | OR url LIKE '%://' || @domain || '#%' | |||
| 9 | - | OR url LIKE '%://' || @domain | |||
| 10 | - | OR url LIKE '%://www.' || @domain || '/%' | |||
| 11 | - | OR url LIKE '%://www.' || @domain || '?%' | |||
| 12 | - | OR url LIKE '%://www.' || @domain || '#%' | |||
| 13 | - | OR url LIKE '%://www.' || @domain | |||
| 14 | ORDER BY last_visited DESC | 7 | ORDER BY last_visited DESC | ||
| 15 | LIMIT @limit OFFSET @offset; | 8 | LIMIT @limit OFFSET @offset; | ||
| 16 | 9 | ||||
| @@ -18,14 +11,15 @@ LIMIT @limit OFFSET @offset; | |||||
| 18 | SELECT * FROM history WHERE url = ? LIMIT 1; | 11 | SELECT * FROM history WHERE url = ? LIMIT 1; | ||
| 19 | 12 | ||||
| 20 | -- name: UpsertHistory :exec | 13 | -- name: UpsertHistory :exec | ||
| 21 | - | INSERT INTO history (url, title, favicon_url) | 14 | + | INSERT INTO history (url, title, favicon_url, domain) |
| 22 | - | VALUES (?, ?, ?) | 15 | + | VALUES (?, ?, ?, ?) |
| 23 | ON CONFLICT(url) | 16 | ON CONFLICT(url) | ||
| 24 | DO UPDATE SET | 17 | DO UPDATE SET | ||
| 25 | visit_count = visit_count + 1, | 18 | visit_count = visit_count + 1, | ||
| 26 | last_visited = CURRENT_TIMESTAMP, | 19 | last_visited = CURRENT_TIMESTAMP, | ||
| 27 | title = COALESCE(EXCLUDED.title, history.title), | 20 | title = COALESCE(EXCLUDED.title, history.title), | ||
| 28 | - | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url); | 21 | + | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url), |
| 22 | + | domain = EXCLUDED.domain; | |||
| 29 | 23 | ||||
| 30 | -- name: IncrementVisitCount :exec | 24 | -- name: IncrementVisitCount :exec | ||
| 31 | UPDATE history | 25 | UPDATE history | ||
| @@ -49,6 +43,9 @@ DELETE FROM history WHERE id = ?; | |||||
| 49 | -- name: DeleteHistoryOlderThan :exec | 43 | -- name: DeleteHistoryOlderThan :exec | ||
| 50 | DELETE FROM history WHERE last_visited < ?; | 44 | DELETE FROM history WHERE last_visited < ?; | ||
| 51 | 45 | ||||
| 46 | + | -- name: DeleteHistorySince :exec | |||
| 47 | + | DELETE FROM history WHERE last_visited >= ?; | |||
| 48 | + | ||||
| 52 | -- name: DeleteAllHistory :exec | 49 | -- name: DeleteAllHistory :exec | ||
| 53 | DELETE FROM history; | 50 | DELETE FROM history; | ||
| 54 | 51 | ||||
| @@ -56,7 +53,7 @@ DELETE FROM history; | |||||
| 56 | SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history; | 53 | SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history; | ||
| 57 | 54 | ||||
| 58 | -- name: GetDomainStats :many | 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 | -- name: GetHourlyDistribution :many | 58 | -- name: GetHourlyDistribution :many | ||
| 62 | SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour; | 59 | SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour; | ||
| @@ -65,15 +62,7 @@ SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_ | |||||
| 65 | SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC; | 62 | SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC; | ||
| 66 | 63 | ||||
| 67 | -- name: DeleteHistoryByDomain :exec | 64 | -- name: DeleteHistoryByDomain :exec | ||
| 68 | - | DELETE FROM history | 65 | + | DELETE FROM history WHERE domain = @domain; |
| 69 | - | WHERE url LIKE '%://' || @domain || '/%' | |||
| 70 | - | OR url LIKE '%://' || @domain || '?%' | |||
| 71 | - | OR url LIKE '%://' || @domain || '#%' | |||
| 72 | - | OR url LIKE '%://' || @domain | |||
| 73 | - | OR url LIKE '%://www.' || @domain || '/%' | |||
| 74 | - | OR url LIKE '%://www.' || @domain || '?%' | |||
| 75 | - | OR url LIKE '%://www.' || @domain || '#%' | |||
| 76 | - | OR url LIKE '%://www.' || @domain; | |||
| 77 | 66 | ||||
| 78 | -- name: SearchHistoryFTSUrl :many | 67 | -- name: SearchHistoryFTSUrl :many | ||
| 79 | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 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 | LIMIT @limit; | 88 | LIMIT @limit; | ||
| 100 | 89 | ||||
| 101 | -- name: SearchHistoryFTSTitle :many | 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 | FROM history_fts fts | 92 | FROM history_fts fts | ||
| 104 | JOIN history h ON fts.rowid = h.id | 93 | JOIN history h ON fts.rowid = h.id | ||
| 105 | WHERE fts.title MATCH @query | 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 | const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec | 37 | const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec | ||
| 38 | - | DELETE FROM history | 38 | + | DELETE FROM history WHERE domain = ?1 |
| 39 | - | WHERE url LIKE '%://' || ?1 || '/%' | |||
| 40 | - | OR url LIKE '%://' || ?1 || '?%' | |||
| 41 | - | OR url LIKE '%://' || ?1 || '#%' | |||
| 42 | - | OR url LIKE '%://' || ?1 | |||
| 43 | - | OR url LIKE '%://www.' || ?1 || '/%' | |||
| 44 | - | OR url LIKE '%://www.' || ?1 || '?%' | |||
| 45 | - | OR url LIKE '%://www.' || ?1 || '#%' | |||
| 46 | - | OR url LIKE '%://www.' || ?1 | |||
| 47 | ` | 39 | ` | ||
| 48 | 40 | ||||
| 49 | func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error { | 41 | func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error { | ||
| @@ -69,8 +61,17 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu | |||||
| 69 | return err | 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 | const GetAllMostVisited = `-- name: GetAllMostVisited :many | 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 | ORDER BY visit_count DESC, last_visited DESC | 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 | &i.VisitCount, | 92 | &i.VisitCount, | ||
| 92 | &i.LastVisited, | 93 | &i.LastVisited, | ||
| 93 | &i.CreatedAt, | 94 | &i.CreatedAt, | ||
| 95 | + | &i.Domain, | |||
| 94 | ); err != nil { | 96 | ); err != nil { | ||
| 95 | return nil, err | 97 | return nil, err | ||
| 96 | } | 98 | } | ||
| @@ -106,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||||
| 106 | } | 108 | } | ||
| 107 | 109 | ||||
| 108 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | 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 | ORDER BY last_visited DESC | 112 | ORDER BY last_visited DESC | ||
| 111 | ` | 113 | ` | ||
| 112 | 114 | ||||
| @@ -127,6 +129,7 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||||
| 127 | &i.VisitCount, | 129 | &i.VisitCount, | ||
| 128 | &i.LastVisited, | 130 | &i.LastVisited, | ||
| 129 | &i.CreatedAt, | 131 | &i.CreatedAt, | ||
| 132 | + | &i.Domain, | |||
| 130 | ); err != nil { | 133 | ); err != nil { | ||
| 131 | return nil, err | 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 | const GetDomainStats = `-- name: GetDomainStats :many | 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 | type GetDomainStatsRow struct { | 184 | type GetDomainStatsRow struct { | ||
| @@ -214,7 +217,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS | |||||
| 214 | } | 217 | } | ||
| 215 | 218 | ||||
| 216 | const GetHistoryByURL = `-- name: GetHistoryByURL :one | 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 | func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) { | 223 | func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) { | ||
| @@ -228,6 +231,7 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err | |||||
| 228 | &i.VisitCount, | 231 | &i.VisitCount, | ||
| 229 | &i.LastVisited, | 232 | &i.LastVisited, | ||
| 230 | &i.CreatedAt, | 233 | &i.CreatedAt, | ||
| 234 | + | &i.Domain, | |||
| 231 | ) | 235 | ) | ||
| 232 | return i, err | 236 | return i, err | ||
| 233 | } | 237 | } | ||
| @@ -282,7 +286,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib | |||||
| 282 | } | 286 | } | ||
| 283 | 287 | ||||
| 284 | const GetMostVisited = `-- name: GetMostVisited :many | 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 | WHERE last_visited >= datetime('now', ?) | 290 | WHERE last_visited >= datetime('now', ?) | ||
| 287 | ORDER BY visit_count DESC, last_visited DESC | 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 | &i.VisitCount, | 308 | &i.VisitCount, | ||
| 305 | &i.LastVisited, | 309 | &i.LastVisited, | ||
| 306 | &i.CreatedAt, | 310 | &i.CreatedAt, | ||
| 311 | + | &i.Domain, | |||
| 307 | ); err != nil { | 312 | ); err != nil { | ||
| 308 | return nil, err | 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 | const GetRecentHistory = `-- name: GetRecentHistory :many | 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 | type GetRecentHistoryParams struct { | 330 | type GetRecentHistoryParams struct { | ||
| @@ -344,6 +349,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 344 | &i.VisitCount, | 349 | &i.VisitCount, | ||
| 345 | &i.LastVisited, | 350 | &i.LastVisited, | ||
| 346 | &i.CreatedAt, | 351 | &i.CreatedAt, | ||
| 352 | + | &i.Domain, | |||
| 347 | ); err != nil { | 353 | ); err != nil { | ||
| 348 | return nil, err | 354 | return nil, err | ||
| 349 | } | 355 | } | ||
| @@ -359,15 +365,8 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 359 | } | 365 | } | ||
| 360 | 366 | ||||
| 361 | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | 367 | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | ||
| 362 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | 368 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history |
| 363 | - | WHERE url LIKE '%://' || ?1 || '/%' | 369 | + | WHERE domain = ?1 |
| 364 | - | OR url LIKE '%://' || ?1 || '?%' | |||
| 365 | - | OR url LIKE '%://' || ?1 || '#%' | |||
| 366 | - | OR url LIKE '%://' || ?1 | |||
| 367 | - | OR url LIKE '%://www.' || ?1 || '/%' | |||
| 368 | - | OR url LIKE '%://www.' || ?1 || '?%' | |||
| 369 | - | OR url LIKE '%://www.' || ?1 || '#%' | |||
| 370 | - | OR url LIKE '%://www.' || ?1 | |||
| 371 | ORDER BY last_visited DESC | 370 | ORDER BY last_visited DESC | ||
| 372 | LIMIT ?3 OFFSET ?2 | 371 | LIMIT ?3 OFFSET ?2 | ||
| 373 | ` | 372 | ` | ||
| @@ -395,6 +394,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis | |||||
| 395 | &i.VisitCount, | 394 | &i.VisitCount, | ||
| 396 | &i.LastVisited, | 395 | &i.LastVisited, | ||
| 397 | &i.CreatedAt, | 396 | &i.CreatedAt, | ||
| 397 | + | &i.Domain, | |||
| 398 | ); err != nil { | 398 | ); err != nil { | ||
| 399 | return nil, err | 399 | return nil, err | ||
| 400 | } | 400 | } | ||
| @@ -410,7 +410,7 @@ func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHis | |||||
| 410 | } | 410 | } | ||
| 411 | 411 | ||||
| 412 | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | 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 | WHERE last_visited >= datetime('now', ?) | 414 | WHERE last_visited >= datetime('now', ?) | ||
| 415 | ORDER BY last_visited DESC | 415 | ORDER BY last_visited DESC | ||
| 416 | ` | 416 | ` | ||
| @@ -432,6 +432,7 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||||
| 432 | &i.VisitCount, | 432 | &i.VisitCount, | ||
| 433 | &i.LastVisited, | 433 | &i.LastVisited, | ||
| 434 | &i.CreatedAt, | 434 | &i.CreatedAt, | ||
| 435 | + | &i.Domain, | |||
| 435 | ); err != nil { | 436 | ); err != nil { | ||
| 436 | return nil, err | 437 | return nil, err | ||
| 437 | } | 438 | } | ||
| @@ -474,7 +475,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV | |||||
| 474 | } | 475 | } | ||
| 475 | 476 | ||||
| 476 | const SearchHistory = `-- name: SearchHistory :many | 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 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | 479 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | ||
| 479 | ORDER BY visit_count DESC, last_visited DESC | 480 | ORDER BY visit_count DESC, last_visited DESC | ||
| 480 | LIMIT ? | 481 | LIMIT ? | ||
| @@ -503,6 +504,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||||
| 503 | &i.VisitCount, | 504 | &i.VisitCount, | ||
| 504 | &i.LastVisited, | 505 | &i.LastVisited, | ||
| 505 | &i.CreatedAt, | 506 | &i.CreatedAt, | ||
| 507 | + | &i.Domain, | |||
| 506 | ); err != nil { | 508 | ); err != nil { | ||
| 507 | return nil, err | 509 | return nil, err | ||
| 508 | } | 510 | } | ||
| @@ -518,7 +520,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||||
| 518 | } | 520 | } | ||
| 519 | 521 | ||||
| 520 | const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many | 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 | FROM history_fts fts | 524 | FROM history_fts fts | ||
| 523 | JOIN history h ON fts.rowid = h.id | 525 | JOIN history h ON fts.rowid = h.id | ||
| 524 | WHERE fts.title MATCH ?1 | 526 | WHERE fts.title MATCH ?1 | ||
| @@ -548,6 +550,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT | |||||
| 548 | &i.VisitCount, | 550 | &i.VisitCount, | ||
| 549 | &i.LastVisited, | 551 | &i.LastVisited, | ||
| 550 | &i.CreatedAt, | 552 | &i.CreatedAt, | ||
| 553 | + | &i.Domain, | |||
| 551 | ); err != nil { | 554 | ); err != nil { | ||
| 552 | return nil, err | 555 | return nil, err | ||
| 553 | } | 556 | } | ||
| @@ -576,15 +579,25 @@ type SearchHistoryFTSUrlParams struct { | |||||
| 576 | Limit int64 `json:"limit"` | 579 | Limit int64 `json:"limit"` | ||
| 577 | } | 580 | } | ||
| 578 | 581 | ||||
| 579 | - | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) { | 582 | + | type SearchHistoryFTSUrlRow struct { |
| 583 | + | ID int64 `json:"id"` | |||
| 584 | + | Url string `json:"url"` | |||
| 585 | + | Title sql.NullString `json:"title"` | |||
| 586 | + | FaviconUrl sql.NullString `json:"favicon_url"` | |||
| 587 | + | VisitCount sql.NullInt64 `json:"visit_count"` | |||
| 588 | + | LastVisited sql.NullTime `json:"last_visited"` | |||
| 589 | + | CreatedAt sql.NullTime `json:"created_at"` | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) { | |||
| 580 | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) | 593 | rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit) | ||
| 581 | if err != nil { | 594 | if err != nil { | ||
| 582 | return nil, err | 595 | return nil, err | ||
| 583 | } | 596 | } | ||
| 584 | defer rows.Close() | 597 | defer rows.Close() | ||
| 585 | - | items := []History{} | 598 | + | items := []SearchHistoryFTSUrlRow{} |
| 586 | for rows.Next() { | 599 | for rows.Next() { | ||
| 587 | - | var i History | 600 | + | var i SearchHistoryFTSUrlRow |
| 588 | if err := rows.Scan( | 601 | if err := rows.Scan( | ||
| 589 | &i.ID, | 602 | &i.ID, | ||
| 590 | &i.Url, | 603 | &i.Url, | ||
| @@ -673,23 +686,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se | |||||
| 673 | } | 686 | } | ||
| 674 | 687 | ||||
| 675 | const UpsertHistory = `-- name: UpsertHistory :exec | 688 | const UpsertHistory = `-- name: UpsertHistory :exec | ||
| 676 | - | INSERT INTO history (url, title, favicon_url) | 689 | + | INSERT INTO history (url, title, favicon_url, domain) |
| 677 | - | VALUES (?, ?, ?) | 690 | + | VALUES (?, ?, ?, ?) |
| 678 | ON CONFLICT(url) | 691 | ON CONFLICT(url) | ||
| 679 | DO UPDATE SET | 692 | DO UPDATE SET | ||
| 680 | visit_count = visit_count + 1, | 693 | visit_count = visit_count + 1, | ||
| 681 | last_visited = CURRENT_TIMESTAMP, | 694 | last_visited = CURRENT_TIMESTAMP, | ||
| 682 | title = COALESCE(EXCLUDED.title, history.title), | 695 | title = COALESCE(EXCLUDED.title, history.title), | ||
| 683 | - | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url) | 696 | + | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url), |
| 697 | + | domain = EXCLUDED.domain | |||
| 684 | ` | 698 | ` | ||
| 685 | 699 | ||||
| 686 | type UpsertHistoryParams struct { | 700 | type UpsertHistoryParams struct { | ||
| 687 | Url string `json:"url"` | 701 | Url string `json:"url"` | ||
| 688 | Title sql.NullString `json:"title"` | 702 | Title sql.NullString `json:"title"` | ||
| 689 | FaviconUrl sql.NullString `json:"favicon_url"` | 703 | FaviconUrl sql.NullString `json:"favicon_url"` | ||
| 704 | + | Domain sql.NullString `json:"domain"` | |||
| 690 | } | 705 | } | ||
| 691 | 706 | ||||
| 692 | func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error { | 707 | func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error { | ||
| 693 | - | _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl) | 708 | + | _, err := q.db.ExecContext(ctx, UpsertHistory, |
| 709 | + | arg.Url, | |||
| 710 | + | arg.Title, | |||
| 711 | + | arg.FaviconUrl, | |||
| 712 | + | arg.Domain, | |||
| 713 | + | ) | |||
| 694 | return err | 714 | return err | ||
| 695 | } | 715 | } | ||
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
| @@ -56,6 +56,7 @@ type History struct { | |||||
| 56 | VisitCount sql.NullInt64 `json:"visit_count"` | 56 | VisitCount sql.NullInt64 `json:"visit_count"` | ||
| 57 | LastVisited sql.NullTime `json:"last_visited"` | 57 | LastVisited sql.NullTime `json:"last_visited"` | ||
| 58 | CreatedAt sql.NullTime `json:"created_at"` | 58 | CreatedAt sql.NullTime `json:"created_at"` | ||
| 59 | + | Domain sql.NullString `json:"domain"` | |||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | type HistoryFt struct { | 62 | type HistoryFt struct { | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -1
| @@ -24,6 +24,7 @@ type Querier interface { | |||||
| 24 | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | 24 | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | ||
| 25 | DeleteHistoryByID(ctx context.Context, id int64) error | 25 | DeleteHistoryByID(ctx context.Context, id int64) error | ||
| 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 26 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| 27 | + | DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error | |||
| 27 | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | 28 | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | ||
| 28 | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | 29 | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | ||
| 29 | DeletePermission(ctx context.Context, arg DeletePermissionParams) error | 30 | DeletePermission(ctx context.Context, arg DeletePermissionParams) error | ||
| @@ -78,7 +79,7 @@ type Querier interface { | |||||
| 78 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | 79 | RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error | ||
| 79 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | 80 | SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error) | ||
| 80 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | 81 | SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error) | ||
| 81 | - | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) | 82 | + | SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) |
| 82 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | 83 | SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error) | ||
| 83 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | 84 | SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error | ||
| 84 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | 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 | func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) { | 57 | func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) { | ||
| 55 | trimmed := bytes.TrimSpace(body) | 58 | trimmed := bytes.TrimSpace(body) | ||
| 56 | if len(trimmed) == 0 { | 59 | if len(trimmed) == 0 { | ||
| @@ -67,21 +70,19 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) | |||||
| 67 | _, hasCallback := topLevel["_callback"] | 70 | _, hasCallback := topLevel["_callback"] | ||
| 68 | _, hasData := topLevel["data"] | 71 | _, hasData := topLevel["data"] | ||
| 69 | 72 | ||||
| 73 | + | // Native callbacks can already return the final bridgeResponse shape. | |||
| 70 | if (hasSuccess || hasRequestID) && !hasCallback { | 74 | if (hasSuccess || hasRequestID) && !hasCallback { | ||
| 71 | var resp bridgeResponse | 75 | var resp bridgeResponse | ||
| 72 | if err := json.Unmarshal(trimmed, &resp); err != nil { | 76 | if err := json.Unmarshal(trimmed, &resp); err != nil { | ||
| 73 | return nil, fmt.Errorf("unmarshal direct bridge response: %w", err) | 77 | return nil, fmt.Errorf("unmarshal direct bridge response: %w", err) | ||
| 74 | } | 78 | } | ||
| 75 | if resp.RequestID == "" { | 79 | if resp.RequestID == "" { | ||
| 80 | + | // Some direct shims omit requestId; preserve correlation for callers. | |||
| 76 | resp.RequestID = requestID | 81 | resp.RequestID = requestID | ||
| 77 | } | 82 | } | ||
| 78 | return json.Marshal(resp) | 83 | return json.Marshal(resp) | ||
| 79 | } | 84 | } | ||
| 80 | 85 | ||||
| 81 | - | if !hasData { | |||
| 82 | - | return nil, fmt.Errorf("wrapped bridge response missing data") | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | var wrapped struct { | 86 | var wrapped struct { | ||
| 86 | Data json.RawMessage `json:"data,omitempty"` | 87 | Data json.RawMessage `json:"data,omitempty"` | ||
| 87 | Error string `json:"error,omitempty"` | 88 | Error string `json:"error,omitempty"` | ||
| @@ -90,20 +91,37 @@ func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) | |||||
| 90 | return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err) | 91 | return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err) | ||
| 91 | } | 92 | } | ||
| 92 | if wrapped.Error != "" { | 93 | if wrapped.Error != "" { | ||
| 94 | + | // Fetch-style wrappers report transport errors outside the data payload. | |||
| 93 | return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error}) | 95 | return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error}) | ||
| 94 | } | 96 | } | ||
| 97 | + | if !hasData { | |||
| 98 | + | // Wrapped shim responses must carry either data or a top-level error. | |||
| 99 | + | return nil, fmt.Errorf("wrapped bridge response missing data") | |||
| 100 | + | } | |||
| 95 | if len(wrapped.Data) == 0 { | 101 | if len(wrapped.Data) == 0 { | ||
| 96 | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | 102 | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | ||
| 97 | } | 103 | } | ||
| 98 | 104 | ||||
| 99 | - | var nested bridgeResponse | 105 | + | var nestedFields map[string]json.RawMessage |
| 100 | - | if err := json.Unmarshal(wrapped.Data, &nested); err == nil { | 106 | + | if err := json.Unmarshal(wrapped.Data, &nestedFields); err == nil { |
| 101 | - | hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != "" | 107 | + | nestedSuccess, nestedHasSuccess := nestedFields["success"] |
| 108 | + | _, nestedHasRequestID := nestedFields["requestId"] | |||
| 109 | + | _, nestedHasError := nestedFields["error"] | |||
| 110 | + | successIsBool := false | |||
| 111 | + | if nestedHasSuccess { | |||
| 112 | + | var successValue bool | |||
| 113 | + | successIsBool = json.Unmarshal(nestedSuccess, &successValue) == nil | |||
| 114 | + | } | |||
| 115 | + | hasNestedEnvelope := successIsBool || (nestedHasRequestID && nestedHasError) | |||
| 102 | if hasNestedEnvelope { | 116 | if hasNestedEnvelope { | ||
| 103 | - | if nested.RequestID == "" { | 117 | + | var nested bridgeResponse |
| 104 | - | nested.RequestID = requestID | 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 | if !window.Truthy() { | 51 | if !window.Truthy() { | ||
| 52 | return false | 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 | func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | 58 | func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | ||
| @@ -83,6 +84,31 @@ func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) | |||||
| 83 | 84 | ||||
| 84 | type webkitTransport struct{} | 85 | type webkitTransport struct{} | ||
| 85 | 86 | ||||
| 87 | + | type webkitCallbackResult struct { | |||
| 88 | + | body []byte | |||
| 89 | + | err error | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | type webkitCallbackWaiter struct { | |||
| 93 | + | requestID string | |||
| 94 | + | successName string | |||
| 95 | + | failureName string | |||
| 96 | + | resultCh chan webkitCallbackResult | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // webkitCallbacks uses one page-lifetime dispatcher per native callback name. | |||
| 100 | + | // Requests sharing the same callback names are intentionally serialized because | |||
| 101 | + | // older native shims do not always echo requestId; late callbacks without a | |||
| 102 | + | // matching waiter are dropped instead of targeting released js.Func handles. | |||
| 103 | + | var webkitCallbacks = struct { | |||
| 104 | + | sync.Mutex | |||
| 105 | + | installed map[string]js.Func | |||
| 106 | + | waiters map[string]*webkitCallbackWaiter | |||
| 107 | + | }{ | |||
| 108 | + | installed: make(map[string]js.Func), | |||
| 109 | + | waiters: make(map[string]*webkitCallbackWaiter), | |||
| 110 | + | } | |||
| 111 | + | ||||
| 86 | func (*webkitTransport) Available() bool { | 112 | func (*webkitTransport) Available() bool { | ||
| 87 | window := js.Global().Get("window") | 113 | window := js.Global().Get("window") | ||
| 88 | if !window.Truthy() { | 114 | if !window.Truthy() { | ||
| @@ -119,73 +145,126 @@ func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |||||
| 119 | return nil, errors.New("webkit message handler not available") | 145 | return nil, errors.New("webkit message handler not available") | ||
| 120 | } | 146 | } | ||
| 121 | 147 | ||||
| 122 | - | type result struct { | 148 | + | ensureWebKitCallback(window, plan.success, false) |
| 123 | - | body []byte | 149 | + | ensureWebKitCallback(window, plan.failure, true) |
| 124 | - | err error | 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) | 156 | + | |
| 127 | - | var callbackMu sync.Mutex | 157 | + | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) |
| 128 | - | callbacksActive := true | 158 | + | |
| 129 | - | publish := func(res result) { | 159 | + | select { |
| 130 | - | callbackMu.Lock() | 160 | + | case res := <-resultCh: |
| 131 | - | active := callbacksActive | 161 | + | return res.body, res.err |
| 132 | - | callbackMu.Unlock() | 162 | + | case <-ctx.Done(): |
| 133 | - | if !active { | 163 | + | unregister() |
| 134 | - | return | 164 | + | return nil, ctx.Err() |
| 135 | - | } | |||
| 136 | - | select { | |||
| 137 | - | case resultCh <- res: | |||
| 138 | - | default: | |||
| 139 | - | } | |||
| 140 | } | 165 | } | ||
| 166 | + | } | |||
| 141 | 167 | ||||
| 142 | - | successFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | 168 | + | func ensureWebKitCallback(window js.Value, name string, failure bool) { |
| 143 | - | payload, err := stringifyArgs(args) | 169 | + | webkitCallbacks.Lock() |
| 144 | - | if err != nil { | 170 | + | if _, ok := webkitCallbacks.installed[name]; ok { |
| 145 | - | publish(result{err: err}) | 171 | + | webkitCallbacks.Unlock() |
| 146 | - | return nil | 172 | + | return |
| 147 | - | } | 173 | + | } |
| 148 | - | normalized, err := normalizeNativeSuccessResponse(req.requestID, payload) | 174 | + | callback := js.FuncOf(func(_ js.Value, args []js.Value) any { |
| 149 | - | publish(result{body: normalized, err: err}) | 175 | + | handleWebKitCallback(name, failure, args) |
| 150 | return nil | 176 | return nil | ||
| 151 | }) | 177 | }) | ||
| 152 | - | errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | 178 | + | webkitCallbacks.installed[name] = callback |
| 153 | - | payload, err := stringifyArgs(args) | 179 | + | // Installed callbacks are page-lifetime dispatchers. Per-request waiters are |
| 154 | - | if err != nil { | 180 | + | // removed on completion/cancellation, so late native responses are dropped |
| 155 | - | publish(result{err: err}) | 181 | + | // without invoking released js.Func handles. |
| 156 | - | return nil | 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) | 206 | + | if webkitCallbacks.waiters[plan.failure] == waiter { |
| 159 | - | publish(result{body: normalized, err: err}) | 207 | + | delete(webkitCallbacks.waiters, plan.failure) |
| 160 | - | return nil | 208 | + | } |
| 161 | - | }) | 209 | + | }, nil |
| 210 | + | } | |||
| 162 | 211 | ||||
| 163 | - | previousSuccess := window.Get(plan.success) | 212 | + | func handleWebKitCallback(name string, failure bool, args []js.Value) { |
| 164 | - | previousError := window.Get(plan.failure) | 213 | + | payload, payloadErr := stringifyArgs(args) |
| 165 | - | window.Set(plan.success, successFn) | 214 | + | payloadRequestID := requestIDFromBridgePayload(payload) |
| 166 | - | window.Set(plan.failure, errorFn) | |||
| 167 | - | defer func() { | |||
| 168 | - | callbackMu.Lock() | |||
| 169 | - | callbacksActive = false | |||
| 170 | - | callbackMu.Unlock() | |||
| 171 | - | window.Set(plan.success, previousSuccess) | |||
| 172 | - | window.Set(plan.failure, previousError) | |||
| 173 | - | successFn.Release() | |||
| 174 | - | errorFn.Release() | |||
| 175 | - | }() | |||
| 176 | 215 | ||||
| 177 | - | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | 216 | + | webkitCallbacks.Lock() |
| 217 | + | waiter := webkitCallbacks.waiters[name] | |||
| 218 | + | if waiter != nil && payloadRequestID != "" && waiter.requestID != "" && payloadRequestID != waiter.requestID { | |||
| 219 | + | webkitCallbacks.Unlock() | |||
| 220 | + | warnDroppedBridgeResult("webkit callback request mismatch", fmt.Errorf("got %s, want %s", payloadRequestID, waiter.requestID)) | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | if waiter != nil { | |||
| 224 | + | delete(webkitCallbacks.waiters, waiter.successName) | |||
| 225 | + | delete(webkitCallbacks.waiters, waiter.failureName) | |||
| 226 | + | } | |||
| 227 | + | webkitCallbacks.Unlock() | |||
| 178 | 228 | ||||
| 229 | + | if waiter == nil { | |||
| 230 | + | warnDroppedBridgeResult("webkit callback without waiter", nil) | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | res := webkitCallbackResult{err: payloadErr} | |||
| 235 | + | if payloadErr == nil { | |||
| 236 | + | if failure { | |||
| 237 | + | res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload) | |||
| 238 | + | } else { | |||
| 239 | + | res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload) | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 179 | select { | 242 | select { | ||
| 180 | - | case res := <-resultCh: | 243 | + | case waiter.resultCh <- res: |
| 181 | - | return res.body, res.err | 244 | + | default: |
| 182 | - | case <-ctx.Done(): | 245 | + | warnDroppedBridgeResult("webkit callback", res.err) |
| 183 | - | return nil, ctx.Err() | |||
| 184 | } | 246 | } | ||
| 185 | } | 247 | } | ||
| 186 | 248 | ||||
| 249 | + | func requestIDFromBridgePayload(payload []byte) string { | |||
| 250 | + | if len(bytes.TrimSpace(payload)) == 0 { | |||
| 251 | + | return "" | |||
| 252 | + | } | |||
| 253 | + | var envelope struct { | |||
| 254 | + | RequestID string `json:"requestId"` | |||
| 255 | + | } | |||
| 256 | + | if err := json.Unmarshal(payload, &envelope); err != nil { | |||
| 257 | + | return "" | |||
| 258 | + | } | |||
| 259 | + | return envelope.RequestID | |||
| 260 | + | } | |||
| 261 | + | ||||
| 187 | func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) { | 262 | func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) { | ||
| 188 | - | response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint)) | 263 | + | fetch := js.Global().Get("fetch") |
| 264 | + | if !fetch.Truthy() { | |||
| 265 | + | return nil, errors.New("fetch API not available") | |||
| 266 | + | } | |||
| 267 | + | response, err := awaitPromise(ctx, fetch.Invoke(endpoint)) | |||
| 189 | if err != nil { | 268 | if err != nil { | ||
| 190 | return nil, err | 269 | return nil, err | ||
| 191 | } | 270 | } | ||
| @@ -243,43 +322,77 @@ func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, err | |||||
| 243 | } | 322 | } | ||
| 244 | 323 | ||||
| 245 | func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | 324 | func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | ||
| 325 | + | if ctx == nil { | |||
| 326 | + | ctx = context.Background() | |||
| 327 | + | } | |||
| 246 | type result struct { | 328 | type result struct { | ||
| 247 | value js.Value | 329 | value js.Value | ||
| 248 | err error | 330 | err error | ||
| 249 | } | 331 | } | ||
| 250 | resultCh := make(chan result, 1) | 332 | resultCh := make(chan result, 1) | ||
| 251 | 333 | ||||
| 334 | + | promiseCtor := js.Global().Get("Promise") | |||
| 335 | + | if !promiseCtor.Truthy() { | |||
| 336 | + | return js.Undefined(), errors.New("Promise global not available") | |||
| 337 | + | } | |||
| 338 | + | var ( | |||
| 339 | + | cancelMu sync.Mutex | |||
| 340 | + | reject = js.Undefined() | |||
| 341 | + | ) | |||
| 342 | + | executor := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 343 | + | if len(args) >= 2 { | |||
| 344 | + | cancelMu.Lock() | |||
| 345 | + | reject = args[1] | |||
| 346 | + | cancelMu.Unlock() | |||
| 347 | + | } | |||
| 348 | + | return nil | |||
| 349 | + | }) | |||
| 350 | + | cancelPromise := promiseCtor.New(executor) | |||
| 351 | + | executor.Release() | |||
| 352 | + | raceInput := js.Global().Get("Array").New(promise, cancelPromise) | |||
| 353 | + | racePromise := promiseCtor.Call("race", raceInput) | |||
| 354 | + | ||||
| 355 | + | done := make(chan struct{}) | |||
| 356 | + | go func() { | |||
| 357 | + | select { | |||
| 358 | + | case <-ctx.Done(): | |||
| 359 | + | cancelMu.Lock() | |||
| 360 | + | rejectFn := reject | |||
| 361 | + | cancelMu.Unlock() | |||
| 362 | + | if rejectFn.Truthy() { | |||
| 363 | + | rejectFn.Invoke(ctx.Err().Error()) | |||
| 364 | + | } | |||
| 365 | + | case <-done: | |||
| 366 | + | } | |||
| 367 | + | }() | |||
| 368 | + | defer close(done) | |||
| 369 | + | ||||
| 252 | var ( | 370 | var ( | ||
| 253 | callbackMu sync.Mutex | 371 | callbackMu sync.Mutex | ||
| 254 | - | active = true | |||
| 255 | released bool | 372 | released bool | ||
| 256 | - | thenFn js.Func | 373 | + | thenCb js.Func |
| 257 | - | catchFn js.Func | 374 | + | catchCb js.Func |
| 258 | ) | 375 | ) | ||
| 259 | - | releaseCallbacksLocked := func() { | 376 | + | releaseCallbacks := func() { |
| 377 | + | callbackMu.Lock() | |||
| 378 | + | defer callbackMu.Unlock() | |||
| 260 | if released { | 379 | if released { | ||
| 261 | return | 380 | return | ||
| 262 | } | 381 | } | ||
| 263 | released = true | 382 | released = true | ||
| 264 | - | thenFn.Release() | 383 | + | thenCb.Release() |
| 265 | - | catchFn.Release() | 384 | + | catchCb.Release() |
| 266 | } | 385 | } | ||
| 267 | publish := func(res result) { | 386 | publish := func(res result) { | ||
| 268 | - | callbackMu.Lock() | |||
| 269 | - | defer callbackMu.Unlock() | |||
| 270 | - | if !active { | |||
| 271 | - | releaseCallbacksLocked() | |||
| 272 | - | return | |||
| 273 | - | } | |||
| 274 | - | active = false | |||
| 275 | select { | 387 | select { | ||
| 276 | case resultCh <- res: | 388 | case resultCh <- res: | ||
| 277 | default: | 389 | default: | ||
| 390 | + | warnDroppedBridgeResult("promise", res.err) | |||
| 278 | } | 391 | } | ||
| 279 | - | releaseCallbacksLocked() | |||
| 280 | } | 392 | } | ||
| 281 | 393 | ||||
| 282 | - | thenFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | 394 | + | thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any { |
| 395 | + | defer releaseCallbacks() | |||
| 283 | if len(args) == 0 { | 396 | if len(args) == 0 { | ||
| 284 | publish(result{value: js.Undefined()}) | 397 | publish(result{value: js.Undefined()}) | ||
| 285 | return nil | 398 | return nil | ||
| @@ -287,31 +400,40 @@ func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | |||||
| 287 | publish(result{value: args[0]}) | 400 | publish(result{value: args[0]}) | ||
| 288 | return nil | 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 | if len(args) == 0 { | 405 | if len(args) == 0 { | ||
| 292 | publish(result{err: errors.New("promise rejected")}) | 406 | publish(result{err: errors.New("promise rejected")}) | ||
| 293 | return nil | 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 | return nil | 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 { | 423 | + | func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) { |
| 302 | - | case res := <-resultCh: | 424 | + | promise.Call("then", thenCb, catchCb) |
| 303 | - | return res.value, res.err | 425 | + | } |
| 304 | - | case <-ctx.Done(): | 426 | + | |
| 305 | - | select { | 427 | + | func warnDroppedBridgeResult(scope string, err error) { |
| 306 | - | case res := <-resultCh: | 428 | + | console := js.Global().Get("console") |
| 307 | - | return res.value, res.err | 429 | + | if !console.Truthy() || !console.Get("warn").Truthy() { |
| 308 | - | default: | 430 | + | return |
| 309 | - | } | |||
| 310 | - | callbackMu.Lock() | |||
| 311 | - | active = false | |||
| 312 | - | callbackMu.Unlock() | |||
| 313 | - | return js.Undefined(), ctx.Err() | |||
| 314 | } | 431 | } | ||
| 432 | + | if err != nil { | |||
| 433 | + | console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error()) | |||
| 434 | + | return | |||
| 435 | + | } | |||
| 436 | + | console.Call("warn", "dumber systemviews bridge dropped "+scope+" result") | |||
| 315 | } | 437 | } | ||
| 316 | 438 | ||||
| 317 | func stringifyArgs(args []js.Value) ([]byte, error) { | 439 | func stringifyArgs(args []js.Value) ([]byte, error) { | ||
| @@ -327,8 +449,11 @@ func stringifyValue(value js.Value) ([]byte, error) { | |||||
| 327 | return nil, errors.New("JSON global not available") | 449 | return nil, errors.New("JSON global not available") | ||
| 328 | } | 450 | } | ||
| 329 | stringified := jsonValue.Call("stringify", value) | 451 | stringified := jsonValue.Call("stringify", value) | ||
| 330 | - | if !stringified.Truthy() { | 452 | + | if stringified.Type() == js.TypeString { |
| 331 | - | return nil, errors.New("failed to stringify JS value") | 453 | + | return []byte(stringified.String()), nil |
| 454 | + | } | |||
| 455 | + | if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) { | |||
| 456 | + | return []byte("null"), nil | |||
| 332 | } | 457 | } | ||
| 333 | - | return []byte(stringified.String()), 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 | func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | 120 | func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | ||
| 87 | t.Parallel() | 121 | t.Parallel() | ||
| 88 | 122 | ||||
| @@ -106,6 +140,50 @@ func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | |||||
| 106 | } | 140 | } | ||
| 107 | } | 141 | } | ||
| 108 | 142 | ||||
| 143 | + | func TestNormalizeBridgeShimResponse_WrapsTopLevelErrorWithoutData(t *testing.T) { | |||
| 144 | + | t.Parallel() | |||
| 145 | + | ||||
| 146 | + | got, err := normalizeBridgeShimResponse("req-error", []byte(`{"error":"bridge failed"}`)) | |||
| 147 | + | if err != nil { | |||
| 148 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | var resp bridgeResponse | |||
| 152 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 153 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 154 | + | } | |||
| 155 | + | if resp.RequestID != "req-error" { | |||
| 156 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-error") | |||
| 157 | + | } | |||
| 158 | + | if resp.Error != "bridge failed" { | |||
| 159 | + | t.Fatalf("error = %q, want %q", resp.Error, "bridge failed") | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestNormalizeBridgeShimResponse_RejectsMalformedJSON(t *testing.T) { | |||
| 164 | + | t.Parallel() | |||
| 165 | + | ||||
| 166 | + | if _, err := normalizeBridgeShimResponse("req-bad", []byte(`{"data":`)); err == nil { | |||
| 167 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestNormalizeBridgeShimResponse_RejectsMissingData(t *testing.T) { | |||
| 172 | + | t.Parallel() | |||
| 173 | + | ||||
| 174 | + | if _, err := normalizeBridgeShimResponse("req-missing", []byte(`{"_callback":"__dumber_config_saved"}`)); err == nil { | |||
| 175 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestNormalizeBridgeShimResponse_RejectsEmptyBody(t *testing.T) { | |||
| 180 | + | t.Parallel() | |||
| 181 | + | ||||
| 182 | + | if _, err := normalizeBridgeShimResponse("req-empty", nil); err == nil { | |||
| 183 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 109 | func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) { | 187 | func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) { | ||
| 110 | t.Parallel() | 188 | t.Parallel() | ||
| 111 | 189 | ||||
M internal/infrastructure/systemviewsbridge/client.go +138 -27
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "strings" | |||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| @@ -20,8 +21,9 @@ type Transport interface { | |||||
| 20 | 21 | ||||
| 21 | // Client sends WebUI message envelopes through the first available transport. | 22 | // Client sends WebUI message envelopes through the first available transport. | ||
| 22 | type Client struct { | 23 | type Client struct { | ||
| 23 | - | native Transport | 24 | + | native Transport |
| 24 | - | fetch Transport | 25 | + | fetch Transport |
| 26 | + | requestTimeout time.Duration | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | var _ port.SystemviewConfigService = (*Client)(nil) | 29 | var _ port.SystemviewConfigService = (*Client)(nil) | ||
| @@ -30,11 +32,27 @@ var _ port.SystemviewFavoritesService = (*Client)(nil) | |||||
| 30 | 32 | ||||
| 31 | var requestSeq atomic.Uint64 | 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 | // NewClient creates a bridge client with native WebKit and fetch transports. | 47 | // NewClient creates a bridge client with native WebKit and fetch transports. | ||
| 36 | - | func NewClient(native, fetch Transport) *Client { | 48 | + | func NewClient(native, fetch Transport, opts ...ClientOption) *Client { |
| 37 | - | return &Client{native: native, fetch: fetch} | 49 | + | client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout} |
| 50 | + | for _, opt := range opts { | |||
| 51 | + | if opt != nil { | |||
| 52 | + | opt(client) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | return client | |||
| 38 | } | 56 | } | ||
| 39 | 57 | ||||
| 40 | // Send builds a WebUI message envelope and sends it through the active transport. | 58 | // Send builds a WebUI message envelope and sends it through the active transport. | ||
| @@ -57,6 +75,12 @@ func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, | |||||
| 57 | } | 75 | } | ||
| 58 | 76 | ||||
| 59 | func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 77 | func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 78 | + | if limit < 0 { | |||
| 79 | + | return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit) | |||
| 80 | + | } | |||
| 81 | + | if offset < 0 { | |||
| 82 | + | return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset) | |||
| 83 | + | } | |||
| 60 | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct { | 84 | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct { | ||
| 61 | RequestID string `json:"requestId"` | 85 | RequestID string `json:"requestId"` | ||
| 62 | Limit int `json:"limit"` | 86 | Limit int `json:"limit"` | ||
| @@ -65,6 +89,16 @@ func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.His | |||||
| 65 | } | 89 | } | ||
| 66 | 90 | ||||
| 67 | func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | 91 | func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 92 | + | domain = strings.TrimSpace(domain) | |||
| 93 | + | if domain == "" { | |||
| 94 | + | return nil, fmt.Errorf("timeline-by-domain domain must be non-empty") | |||
| 95 | + | } | |||
| 96 | + | if limit < 0 { | |||
| 97 | + | return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit) | |||
| 98 | + | } | |||
| 99 | + | if offset < 0 { | |||
| 100 | + | return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset) | |||
| 101 | + | } | |||
| 68 | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct { | 102 | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct { | ||
| 69 | RequestID string `json:"requestId"` | 103 | RequestID string `json:"requestId"` | ||
| 70 | Domain string `json:"domain"` | 104 | Domain string `json:"domain"` | ||
| @@ -74,6 +108,9 @@ func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, off | |||||
| 74 | } | 108 | } | ||
| 75 | 109 | ||||
| 76 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | 110 | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | ||
| 111 | + | if limit < 0 { | |||
| 112 | + | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | |||
| 113 | + | } | |||
| 77 | return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct { | 114 | return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct { | ||
| 78 | RequestID string `json:"requestId"` | 115 | RequestID string `json:"requestId"` | ||
| 79 | Query string `json:"query"` | 116 | Query string `json:"query"` | ||
| @@ -82,6 +119,9 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity | |||||
| 82 | } | 119 | } | ||
| 83 | 120 | ||||
| 84 | func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | 121 | func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | ||
| 122 | + | if id <= 0 { | |||
| 123 | + | return fmt.Errorf("delete entry id must be positive, got %d", id) | |||
| 124 | + | } | |||
| 85 | _, err := request[struct{}](c, ctx, "history_delete_entry", struct { | 125 | _, err := request[struct{}](c, ctx, "history_delete_entry", struct { | ||
| 86 | RequestID string `json:"requestId"` | 126 | RequestID string `json:"requestId"` | ||
| 87 | ID int64 `json:"id"` | 127 | ID int64 `json:"id"` | ||
| @@ -90,6 +130,10 @@ func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | |||||
| 90 | } | 130 | } | ||
| 91 | 131 | ||||
| 92 | func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | 132 | func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | ||
| 133 | + | rangeID = strings.TrimSpace(rangeID) | |||
| 134 | + | if rangeID == "" { | |||
| 135 | + | return errors.New("history range ID cannot be empty") | |||
| 136 | + | } | |||
| 93 | _, err := request[struct{}](c, ctx, "history_delete_range", struct { | 137 | _, err := request[struct{}](c, ctx, "history_delete_range", struct { | ||
| 94 | RequestID string `json:"requestId"` | 138 | RequestID string `json:"requestId"` | ||
| 95 | Range string `json:"range"` | 139 | Range string `json:"range"` | ||
| @@ -97,12 +141,16 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | |||||
| 97 | return err | 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 | func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | 146 | func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | ||
| 101 | return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct { | 147 | return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct { | ||
| 102 | RequestID string `json:"requestId"` | 148 | RequestID string `json:"requestId"` | ||
| 103 | }{RequestID: nextRequestID()}) | 149 | }{RequestID: nextRequestID()}) | ||
| 104 | } | 150 | } | ||
| 105 | 151 | ||||
| 152 | + | // Default intentionally targets the direct HTTP config endpoint rather than a | |||
| 153 | + | // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default. | |||
| 106 | func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | 154 | func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | ||
| 107 | return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct { | 155 | return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct { | ||
| 108 | RequestID string `json:"requestId"` | 156 | RequestID string `json:"requestId"` | ||
| @@ -143,6 +191,9 @@ func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error | |||||
| 143 | } | 191 | } | ||
| 144 | 192 | ||||
| 145 | func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) { | 193 | func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) { | ||
| 194 | + | if limit < 0 { | |||
| 195 | + | return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit) | |||
| 196 | + | } | |||
| 146 | return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct { | 197 | return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct { | ||
| 147 | RequestID string `json:"requestId"` | 198 | RequestID string `json:"requestId"` | ||
| 148 | Limit int `json:"limit"` | 199 | Limit int `json:"limit"` | ||
| @@ -150,6 +201,10 @@ func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainSt | |||||
| 150 | } | 201 | } | ||
| 151 | 202 | ||||
| 152 | func (c *Client) DeleteDomain(ctx context.Context, domain string) error { | 203 | func (c *Client) DeleteDomain(ctx context.Context, domain string) error { | ||
| 204 | + | domain = strings.TrimSpace(domain) | |||
| 205 | + | if domain == "" { | |||
| 206 | + | return errors.New("history domain cannot be empty") | |||
| 207 | + | } | |||
| 153 | _, err := request[struct{}](c, ctx, "history_delete_domain", struct { | 208 | _, err := request[struct{}](c, ctx, "history_delete_domain", struct { | ||
| 154 | RequestID string `json:"requestId"` | 209 | RequestID string `json:"requestId"` | ||
| 155 | Domain string `json:"domain"` | 210 | Domain string `json:"domain"` | ||
| @@ -168,10 +223,16 @@ func (c *Client) CreateFavorite(ctx context.Context, input port.FavoriteCreateIn | |||||
| 168 | } | 223 | } | ||
| 169 | 224 | ||||
| 170 | func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | 225 | func (c *Client) UpdateFavorite(ctx context.Context, input port.FavoriteUpdateInput) (*entity.Favorite, error) { | ||
| 226 | + | if input.ID <= 0 { | |||
| 227 | + | return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID) | |||
| 228 | + | } | |||
| 171 | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | 229 | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | ||
| 172 | } | 230 | } | ||
| 173 | 231 | ||||
| 174 | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | 232 | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | ||
| 233 | + | if id <= 0 { | |||
| 234 | + | return fmt.Errorf("delete favorite id must be positive, got %d", id) | |||
| 235 | + | } | |||
| 175 | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | 236 | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | ||
| 176 | RequestID string `json:"requestId"` | 237 | RequestID string `json:"requestId"` | ||
| 177 | ID int64 `json:"id"` | 238 | ID int64 `json:"id"` | ||
| @@ -244,6 +305,9 @@ func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) { | |||||
| 244 | } | 305 | } | ||
| 245 | 306 | ||||
| 246 | func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error { | 307 | func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error { | ||
| 308 | + | if favoriteID <= 0 { | |||
| 309 | + | return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID) | |||
| 310 | + | } | |||
| 247 | _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct { | 311 | _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct { | ||
| 248 | RequestID string `json:"requestId"` | 312 | RequestID string `json:"requestId"` | ||
| 249 | FavoriteID int64 `json:"favorite_id"` | 313 | FavoriteID int64 `json:"favorite_id"` | ||
| @@ -253,6 +317,9 @@ func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey | |||||
| 253 | } | 317 | } | ||
| 254 | 318 | ||||
| 255 | func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error { | 319 | func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error { | ||
| 320 | + | if favoriteID <= 0 { | |||
| 321 | + | return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID) | |||
| 322 | + | } | |||
| 256 | _, err := request[struct{}](c, ctx, "favorite_set_folder", struct { | 323 | _, err := request[struct{}](c, ctx, "favorite_set_folder", struct { | ||
| 257 | RequestID string `json:"requestId"` | 324 | RequestID string `json:"requestId"` | ||
| 258 | FavoriteID int64 `json:"favorite_id"` | 325 | FavoriteID int64 `json:"favorite_id"` | ||
| @@ -261,19 +328,34 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6 | |||||
| 261 | return err | 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 | return request[*entity.Folder](c, ctx, "folder_create", struct { | 340 | return request[*entity.Folder](c, ctx, "folder_create", struct { | ||
| 266 | RequestID string `json:"requestId"` | 341 | RequestID string `json:"requestId"` | ||
| 267 | Name string `json:"name"` | 342 | Name string `json:"name"` | ||
| 268 | Icon *string `json:"icon"` | 343 | Icon *string `json:"icon"` | ||
| 269 | ParentID *int64 `json:"parent_id,omitempty"` | 344 | ParentID *int64 `json:"parent_id,omitempty"` | ||
| 270 | - | }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID}) | 345 | + | }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID}) |
| 271 | } | 346 | } | ||
| 272 | 347 | ||||
| 273 | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | 348 | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | ||
| 349 | + | if id <= 0 { | |||
| 350 | + | return fmt.Errorf("update folder id must be positive, got %d", id) | |||
| 351 | + | } | |||
| 352 | + | name = strings.TrimSpace(name) | |||
| 353 | + | if name == "" { | |||
| 354 | + | return fmt.Errorf("folder name is required") | |||
| 355 | + | } | |||
| 274 | var iconPtr *string | 356 | var iconPtr *string | ||
| 275 | - | if icon != "" { | 357 | + | if trimmed := strings.TrimSpace(icon); trimmed != "" { |
| 276 | - | iconPtr = &icon | 358 | + | iconPtr = &trimmed |
| 277 | } | 359 | } | ||
| 278 | _, err := request[struct{}](c, ctx, "folder_update", struct { | 360 | _, err := request[struct{}](c, ctx, "folder_update", struct { | ||
| 279 | RequestID string `json:"requestId"` | 361 | RequestID string `json:"requestId"` | ||
| @@ -285,6 +367,9 @@ func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) | |||||
| 285 | } | 367 | } | ||
| 286 | 368 | ||||
| 287 | func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | 369 | func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | ||
| 370 | + | if id <= 0 { | |||
| 371 | + | return fmt.Errorf("delete folder id must be positive, got %d", id) | |||
| 372 | + | } | |||
| 288 | _, err := request[struct{}](c, ctx, "folder_delete", struct { | 373 | _, err := request[struct{}](c, ctx, "folder_delete", struct { | ||
| 289 | RequestID string `json:"requestId"` | 374 | RequestID string `json:"requestId"` | ||
| 290 | ID int64 `json:"id"` | 375 | ID int64 `json:"id"` | ||
| @@ -293,9 +378,13 @@ func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | |||||
| 293 | } | 378 | } | ||
| 294 | 379 | ||||
| 295 | func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) { | 380 | func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) { | ||
| 381 | + | name = strings.TrimSpace(name) | |||
| 382 | + | if name == "" { | |||
| 383 | + | return nil, fmt.Errorf("tag name is required") | |||
| 384 | + | } | |||
| 296 | var colorPtr *string | 385 | var colorPtr *string | ||
| 297 | - | if color != "" { | 386 | + | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { |
| 298 | - | colorPtr = &color | 387 | + | colorPtr = &trimmedColor |
| 299 | } | 388 | } | ||
| 300 | return request[*entity.Tag](c, ctx, "tag_create", struct { | 389 | return request[*entity.Tag](c, ctx, "tag_create", struct { | ||
| 301 | RequestID string `json:"requestId"` | 390 | RequestID string `json:"requestId"` | ||
| @@ -305,13 +394,16 @@ func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag | |||||
| 305 | } | 394 | } | ||
| 306 | 395 | ||||
| 307 | func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error { | 396 | func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error { | ||
| 397 | + | if id <= 0 { | |||
| 398 | + | return fmt.Errorf("update tag id must be positive, got %d", id) | |||
| 399 | + | } | |||
| 308 | var namePtr *string | 400 | var namePtr *string | ||
| 309 | - | if name != "" { | 401 | + | if trimmedName := strings.TrimSpace(name); trimmedName != "" { |
| 310 | - | namePtr = &name | 402 | + | namePtr = &trimmedName |
| 311 | } | 403 | } | ||
| 312 | var colorPtr *string | 404 | var colorPtr *string | ||
| 313 | - | if color != "" { | 405 | + | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { |
| 314 | - | colorPtr = &color | 406 | + | colorPtr = &trimmedColor |
| 315 | } | 407 | } | ||
| 316 | _, err := request[struct{}](c, ctx, "tag_update", struct { | 408 | _, err := request[struct{}](c, ctx, "tag_update", struct { | ||
| 317 | RequestID string `json:"requestId"` | 409 | RequestID string `json:"requestId"` | ||
| @@ -323,6 +415,9 @@ func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) er | |||||
| 323 | } | 415 | } | ||
| 324 | 416 | ||||
| 325 | func (c *Client) DeleteTag(ctx context.Context, id int64) error { | 417 | func (c *Client) DeleteTag(ctx context.Context, id int64) error { | ||
| 418 | + | if id <= 0 { | |||
| 419 | + | return fmt.Errorf("delete tag id must be positive, got %d", id) | |||
| 420 | + | } | |||
| 326 | _, err := request[struct{}](c, ctx, "tag_delete", struct { | 421 | _, err := request[struct{}](c, ctx, "tag_delete", struct { | ||
| 327 | RequestID string `json:"requestId"` | 422 | RequestID string `json:"requestId"` | ||
| 328 | ID int64 `json:"id"` | 423 | ID int64 `json:"id"` | ||
| @@ -331,6 +426,12 @@ func (c *Client) DeleteTag(ctx context.Context, id int64) error { | |||||
| 331 | } | 426 | } | ||
| 332 | 427 | ||||
| 333 | func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | 428 | func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | ||
| 429 | + | if favoriteID <= 0 { | |||
| 430 | + | return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID) | |||
| 431 | + | } | |||
| 432 | + | if tagID <= 0 { | |||
| 433 | + | return fmt.Errorf("assign tag id must be positive, got %d", tagID) | |||
| 434 | + | } | |||
| 334 | _, err := request[struct{}](c, ctx, "tag_assign", struct { | 435 | _, err := request[struct{}](c, ctx, "tag_assign", struct { | ||
| 335 | RequestID string `json:"requestId"` | 436 | RequestID string `json:"requestId"` | ||
| 336 | FavoriteID int64 `json:"favorite_id"` | 437 | FavoriteID int64 `json:"favorite_id"` | ||
| @@ -340,6 +441,12 @@ func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | |||||
| 340 | } | 441 | } | ||
| 341 | 442 | ||||
| 342 | func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error { | 443 | func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error { | ||
| 444 | + | if favoriteID <= 0 { | |||
| 445 | + | return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID) | |||
| 446 | + | } | |||
| 447 | + | if tagID <= 0 { | |||
| 448 | + | return fmt.Errorf("remove tag id must be positive, got %d", tagID) | |||
| 449 | + | } | |||
| 343 | _, err := request[struct{}](c, ctx, "tag_remove", struct { | 450 | _, err := request[struct{}](c, ctx, "tag_remove", struct { | ||
| 344 | RequestID string `json:"requestId"` | 451 | RequestID string `json:"requestId"` | ||
| 345 | FavoriteID int64 `json:"favorite_id"` | 452 | FavoriteID int64 `json:"favorite_id"` | ||
| @@ -364,7 +471,7 @@ func (c *Client) transport() Transport { | |||||
| 364 | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | 471 | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | ||
| 365 | var zero T | 472 | var zero T | ||
| 366 | 473 | ||||
| 367 | - | ctx, cancel := withBridgeRequestTimeout(ctx) | 474 | + | ctx, cancel := c.withBridgeRequestTimeout(ctx) |
| 368 | defer cancel() | 475 | defer cancel() | ||
| 369 | 476 | ||||
| 370 | raw, err := c.Send(ctx, msgType, payload) | 477 | raw, err := c.Send(ctx, msgType, payload) | ||
| @@ -375,14 +482,18 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any) | |||||
| 375 | return decodeBridgeResponse[T](raw) | 482 | return decodeBridgeResponse[T](raw) | ||
| 376 | } | 483 | } | ||
| 377 | 484 | ||||
| 378 | - | func withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { | 485 | + | func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { |
| 379 | if ctx == nil { | 486 | if ctx == nil { | ||
| 380 | ctx = context.Background() | 487 | ctx = context.Background() | ||
| 381 | } | 488 | } | ||
| 382 | - | if _, ok := ctx.Deadline(); ok || bridgeRequestTimeout <= 0 { | 489 | + | timeout := defaultBridgeRequestTimeout |
| 490 | + | if c != nil { | |||
| 491 | + | timeout = c.requestTimeout | |||
| 492 | + | } | |||
| 493 | + | if _, ok := ctx.Deadline(); ok || timeout <= 0 { | |||
| 383 | return context.WithCancel(ctx) | 494 | return context.WithCancel(ctx) | ||
| 384 | } | 495 | } | ||
| 385 | - | return context.WithTimeout(ctx, bridgeRequestTimeout) | 496 | + | return context.WithTimeout(ctx, timeout) |
| 386 | } | 497 | } | ||
| 387 | 498 | ||||
| 388 | type bridgeResponse struct { | 499 | type bridgeResponse struct { | ||
| @@ -393,27 +504,27 @@ type bridgeResponse struct { | |||||
| 393 | } | 504 | } | ||
| 394 | 505 | ||||
| 395 | func decodeBridgeResponse[T any](body []byte) (T, error) { | 506 | func decodeBridgeResponse[T any](body []byte) (T, error) { | ||
| 396 | - | var zero T | 507 | + | var result T |
| 397 | 508 | ||||
| 398 | var resp bridgeResponse | 509 | var resp bridgeResponse | ||
| 399 | if err := json.Unmarshal(body, &resp); err != nil { | 510 | if err := json.Unmarshal(body, &resp); err != nil { | ||
| 400 | - | return zero, fmt.Errorf("unmarshal bridge response: %w", err) | 511 | + | return result, fmt.Errorf("unmarshal bridge response: %w", err) |
| 401 | } | 512 | } | ||
| 402 | if !resp.Success { | 513 | if !resp.Success { | ||
| 403 | if resp.Error == "" { | 514 | if resp.Error == "" { | ||
| 404 | - | return zero, errors.New("bridge request failed") | 515 | + | return result, errors.New("bridge request failed") |
| 405 | } | 516 | } | ||
| 406 | - | return zero, errors.New(resp.Error) | 517 | + | return result, errors.New(resp.Error) |
| 407 | } | 518 | } | ||
| 408 | if len(resp.Data) == 0 { | 519 | if len(resp.Data) == 0 { | ||
| 409 | - | return zero, nil | 520 | + | return result, nil |
| 410 | } | 521 | } | ||
| 411 | 522 | ||||
| 412 | - | if err := json.Unmarshal(resp.Data, &zero); err != nil { | 523 | + | if err := json.Unmarshal(resp.Data, &result); err != nil { |
| 413 | - | return zero, fmt.Errorf("unmarshal bridge data: %w", err) | 524 | + | return result, fmt.Errorf("unmarshal bridge data: %w", err) |
| 414 | } | 525 | } | ||
| 415 | 526 | ||||
| 416 | - | return zero, nil | 527 | + | return result, nil |
| 417 | } | 528 | } | ||
| 418 | 529 | ||||
| 419 | func nextRequestID() string { | 530 | func nextRequestID() string { | ||
M internal/infrastructure/systemviewsbridge/client_test.go +227 -8
| @@ -126,6 +126,21 @@ func TestClientTimelineDecodesEntries(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | + | func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) { | |||
| 130 | + | t.Parallel() | |||
| 131 | + | ||||
| 132 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)} | |||
| 133 | + | client := NewClient(transport, nil) | |||
| 134 | + | ||||
| 135 | + | err := client.DeleteRange(context.Background(), " ") | |||
| 136 | + | if err == nil { | |||
| 137 | + | t.Fatal("DeleteRange() error = nil, want validation error") | |||
| 138 | + | } | |||
| 139 | + | if transport.called { | |||
| 140 | + | t.Fatal("DeleteRange() sent a request for an invalid range") | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 129 | func TestClientDeleteRangeSendsRange(t *testing.T) { | 144 | func TestClientDeleteRangeSendsRange(t *testing.T) { | ||
| 130 | t.Parallel() | 145 | t.Parallel() | ||
| 131 | 146 | ||||
| @@ -357,12 +372,10 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 357 | } | 372 | } | ||
| 358 | 373 | ||||
| 359 | func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | 374 | func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | ||
| 360 | - | oldTimeout := bridgeRequestTimeout | 375 | + | t.Parallel() |
| 361 | - | bridgeRequestTimeout = time.Millisecond | |||
| 362 | - | t.Cleanup(func() { bridgeRequestTimeout = oldTimeout }) | |||
| 363 | 376 | ||||
| 364 | transport := &blockingTransport{available: true} | 377 | transport := &blockingTransport{available: true} | ||
| 365 | - | client := NewClient(transport, nil) | 378 | + | client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond)) |
| 366 | 379 | ||||
| 367 | _, err := client.Timeline(context.Background(), 25, 0) | 380 | _, err := client.Timeline(context.Background(), 25, 0) | ||
| 368 | if !errors.Is(err, context.DeadlineExceeded) { | 381 | if !errors.Is(err, context.DeadlineExceeded) { | ||
| @@ -377,10 +390,10 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||||
| 377 | t.Parallel() | 390 | t.Parallel() | ||
| 378 | 391 | ||||
| 379 | parentID := int64(42) | 392 | parentID := int64(42) | ||
| 380 | - | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)} | 393 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)} |
| 381 | client := NewClient(transport, nil) | 394 | client := NewClient(transport, nil) | ||
| 382 | 395 | ||||
| 383 | - | folder, err := client.CreateFolder(context.Background(), "Nested", &parentID) | 396 | + | folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID) |
| 384 | if err != nil { | 397 | if err != nil { | ||
| 385 | t.Fatalf("CreateFolder() error = %v", err) | 398 | t.Fatalf("CreateFolder() error = %v", err) | ||
| 386 | } | 399 | } | ||
| @@ -390,6 +403,9 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||||
| 390 | if folder.Name != "Nested" { | 403 | if folder.Name != "Nested" { | ||
| 391 | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | 404 | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | ||
| 392 | } | 405 | } | ||
| 406 | + | if folder.Icon != "📁" { | |||
| 407 | + | t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon) | |||
| 408 | + | } | |||
| 393 | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | 409 | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | ||
| 394 | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | 410 | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | ||
| 395 | } | 411 | } | ||
| @@ -417,14 +433,155 @@ func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||||
| 417 | if payload.Name != "Nested" { | 433 | if payload.Name != "Nested" { | ||
| 418 | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | 434 | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | ||
| 419 | } | 435 | } | ||
| 420 | - | if payload.Icon != nil { | 436 | + | if payload.Icon == nil || *payload.Icon != "📁" { |
| 421 | - | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | 437 | + | t.Fatalf("sent icon = %#v, want 📁", payload.Icon) |
| 422 | } | 438 | } | ||
| 423 | if payload.RequestID == "" { | 439 | if payload.RequestID == "" { | ||
| 424 | t.Fatal("sent requestId was empty") | 440 | t.Fatal("sent requestId was empty") | ||
| 425 | } | 441 | } | ||
| 426 | } | 442 | } | ||
| 427 | 443 | ||||
| 444 | + | func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) { | |||
| 445 | + | t.Parallel() | |||
| 446 | + | ||||
| 447 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)} | |||
| 448 | + | client := NewClient(transport, nil) | |||
| 449 | + | ||||
| 450 | + | _, err := client.DomainStats(context.Background(), -1) | |||
| 451 | + | if err == nil { | |||
| 452 | + | t.Fatal("DomainStats() error = nil, want validation error") | |||
| 453 | + | } | |||
| 454 | + | if transport.called { | |||
| 455 | + | t.Fatal("DomainStats() sent a request for a negative limit") | |||
| 456 | + | } | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) { | |||
| 460 | + | t.Parallel() | |||
| 461 | + | ||||
| 462 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)} | |||
| 463 | + | client := NewClient(transport, nil) | |||
| 464 | + | ||||
| 465 | + | err := client.DeleteDomain(context.Background(), " ") | |||
| 466 | + | if err == nil { | |||
| 467 | + | t.Fatal("DeleteDomain() error = nil, want validation error") | |||
| 468 | + | } | |||
| 469 | + | if transport.called { | |||
| 470 | + | t.Fatal("DeleteDomain() sent a request for an empty domain") | |||
| 471 | + | } | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | func TestClientUpdateFolderTrimsIcon(t *testing.T) { | |||
| 475 | + | t.Parallel() | |||
| 476 | + | ||||
| 477 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","success":true}`)} | |||
| 478 | + | client := NewClient(transport, nil) | |||
| 479 | + | ||||
| 480 | + | if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil { | |||
| 481 | + | t.Fatalf("UpdateFolder() error = %v", err) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | var msg port.WebUIMessage | |||
| 485 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 486 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 487 | + | } | |||
| 488 | + | if msg.Type != "folder_update" { | |||
| 489 | + | t.Fatalf("sent type = %q, want folder_update", msg.Type) | |||
| 490 | + | } | |||
| 491 | + | var payload struct { | |||
| 492 | + | Icon *string `json:"icon"` | |||
| 493 | + | } | |||
| 494 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 495 | + | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 496 | + | } | |||
| 497 | + | if payload.Icon == nil || *payload.Icon != "📚" { | |||
| 498 | + | t.Fatalf("sent icon = %#v, want 📚", payload.Icon) | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) { | |||
| 503 | + | t.Parallel() | |||
| 504 | + | ||||
| 505 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)} | |||
| 506 | + | client := NewClient(transport, nil) | |||
| 507 | + | ||||
| 508 | + | if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil { | |||
| 509 | + | t.Fatalf("UpdateFolder() error = %v", err) | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | var msg port.WebUIMessage | |||
| 513 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 514 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 515 | + | } | |||
| 516 | + | var payload struct { | |||
| 517 | + | Icon *string `json:"icon"` | |||
| 518 | + | } | |||
| 519 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 520 | + | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 521 | + | } | |||
| 522 | + | if payload.Icon != nil { | |||
| 523 | + | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |||
| 524 | + | } | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func TestClientDeleteEntryRejectsInvalidID(t *testing.T) { | |||
| 528 | + | t.Parallel() | |||
| 529 | + | ||||
| 530 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)} | |||
| 531 | + | client := NewClient(transport, nil) | |||
| 532 | + | ||||
| 533 | + | err := client.DeleteEntry(context.Background(), 0) | |||
| 534 | + | if err == nil { | |||
| 535 | + | t.Fatal("DeleteEntry() error = nil, want validation error") | |||
| 536 | + | } | |||
| 537 | + | if transport.called { | |||
| 538 | + | t.Fatal("DeleteEntry() sent a request for an invalid id") | |||
| 539 | + | } | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | func TestClientTagActionsTrimFields(t *testing.T) { | |||
| 543 | + | t.Parallel() | |||
| 544 | + | ||||
| 545 | + | createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)} | |||
| 546 | + | client := NewClient(createTransport, nil) | |||
| 547 | + | if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil { | |||
| 548 | + | t.Fatalf("CreateTag() error = %v", err) | |||
| 549 | + | } | |||
| 550 | + | var msg port.WebUIMessage | |||
| 551 | + | if err := json.Unmarshal(createTransport.last, &msg); err != nil { | |||
| 552 | + | t.Fatalf("unmarshal create envelope: %v", err) | |||
| 553 | + | } | |||
| 554 | + | var createPayload struct { | |||
| 555 | + | Name string `json:"name"` | |||
| 556 | + | Color *string `json:"color"` | |||
| 557 | + | } | |||
| 558 | + | if err := json.Unmarshal(msg.Payload, &createPayload); err != nil { | |||
| 559 | + | t.Fatalf("unmarshal create payload: %v", err) | |||
| 560 | + | } | |||
| 561 | + | if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" { | |||
| 562 | + | t.Fatalf("CreateTag payload = %#v", createPayload) | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)} | |||
| 566 | + | client = NewClient(updateTransport, nil) | |||
| 567 | + | if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil { | |||
| 568 | + | t.Fatalf("UpdateTag() error = %v", err) | |||
| 569 | + | } | |||
| 570 | + | if err := json.Unmarshal(updateTransport.last, &msg); err != nil { | |||
| 571 | + | t.Fatalf("unmarshal update envelope: %v", err) | |||
| 572 | + | } | |||
| 573 | + | var updatePayload struct { | |||
| 574 | + | Name *string `json:"name"` | |||
| 575 | + | Color *string `json:"color"` | |||
| 576 | + | } | |||
| 577 | + | if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil { | |||
| 578 | + | t.Fatalf("unmarshal update payload: %v", err) | |||
| 579 | + | } | |||
| 580 | + | if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil { | |||
| 581 | + | t.Fatalf("UpdateTag payload = %#v", updatePayload) | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 428 | type fakeTransport struct { | 585 | type fakeTransport struct { | ||
| 429 | available bool | 586 | available bool | ||
| 430 | called bool | 587 | called bool | ||
| @@ -452,3 +609,65 @@ func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) | |||||
| 452 | <-ctx.Done() | 609 | <-ctx.Done() | ||
| 453 | return nil, ctx.Err() | 610 | return nil, ctx.Err() | ||
| 454 | } | 611 | } | ||
| 612 | + | ||||
| 613 | + | func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) { | |||
| 614 | + | t.Parallel() | |||
| 615 | + | ||||
| 616 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)} | |||
| 617 | + | client := NewClient(transport, nil) | |||
| 618 | + | ||||
| 619 | + | checks := []struct { | |||
| 620 | + | name string | |||
| 621 | + | call func() error | |||
| 622 | + | }{ | |||
| 623 | + | {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }}, | |||
| 624 | + | {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }}, | |||
| 625 | + | {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }}, | |||
| 626 | + | {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }}, | |||
| 627 | + | {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }}, | |||
| 628 | + | {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }}, | |||
| 629 | + | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | |||
| 630 | + | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | |||
| 631 | + | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | |||
| 632 | + | {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }}, | |||
| 633 | + | {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }}, | |||
| 634 | + | {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }}, | |||
| 635 | + | {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }}, | |||
| 636 | + | {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }}, | |||
| 637 | + | } | |||
| 638 | + | for _, check := range checks { | |||
| 639 | + | t.Run(check.name, func(t *testing.T) { | |||
| 640 | + | if err := check.call(); err == nil { | |||
| 641 | + | t.Fatal("error = nil, want validation error") | |||
| 642 | + | } | |||
| 643 | + | }) | |||
| 644 | + | } | |||
| 645 | + | if transport.called { | |||
| 646 | + | t.Fatal("transport called for invalid favorite mutation IDs") | |||
| 647 | + | } | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | func TestClientRejectsNegativeHistoryLimits(t *testing.T) { | |||
| 651 | + | t.Parallel() | |||
| 652 | + | ||||
| 653 | + | client := NewClient(&fakeTransport{available: true}, nil) | |||
| 654 | + | ||||
| 655 | + | if _, err := client.Timeline(context.Background(), -1, 0); err == nil { | |||
| 656 | + | t.Fatal("Timeline() error = nil, want error") | |||
| 657 | + | } | |||
| 658 | + | if _, err := client.Timeline(context.Background(), 1, -1); err == nil { | |||
| 659 | + | t.Fatal("Timeline() error = nil, want error") | |||
| 660 | + | } | |||
| 661 | + | if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil { | |||
| 662 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |||
| 663 | + | } | |||
| 664 | + | if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil { | |||
| 665 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |||
| 666 | + | } | |||
| 667 | + | if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil { | |||
| 668 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |||
| 669 | + | } | |||
| 670 | + | if _, err := client.Search(context.Background(), "example", -1); err == nil { | |||
| 671 | + | t.Fatal("Search() error = nil, want error") | |||
| 672 | + | } | |||
| 673 | + | } | |||
A internal/infrastructure/transcoder/http_client_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package transcoder | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/url" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/rs/zerolog" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) { | |||
| 16 | + | source, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 22 | + | if err == nil { | |||
| 23 | + | t.Fatal("validateHTTPTranscodeURL() expected error for loopback target") | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 28 | + | tests := []string{ | |||
| 29 | + | "100.64.0.1", | |||
| 30 | + | "198.18.0.1", | |||
| 31 | + | "192.0.2.1", | |||
| 32 | + | "64:ff9b::a00:1", | |||
| 33 | + | "2001::1", | |||
| 34 | + | "2001:db8::1", | |||
| 35 | + | "2002:0a00:0001::1", | |||
| 36 | + | } | |||
| 37 | + | for _, rawIP := range tests { | |||
| 38 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 39 | + | if !isBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 40 | + | t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 41 | + | } | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) { | |||
| 47 | + | source, err := url.Parse("file:///etc/passwd") | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 53 | + | if err == nil { | |||
| 54 | + | t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) { | |||
| 59 | + | source, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 60 | + | if err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 65 | + | if err == nil { | |||
| 66 | + | t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) { | |||
| 71 | + | source, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if err := validateHTTPTranscodeURL(context.Background(), source); err != nil { | |||
| 77 | + | t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) { | |||
| 82 | + | p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop()) | |||
| 83 | + | ||||
| 84 | + | _, _, err := p.openInputFormatContext(context.Background()) | |||
| 85 | + | if err == nil { | |||
| 86 | + | t.Fatal("openInputFormatContext() expected error for streaming manifest") | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) { | |||
| 91 | + | resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}} | |||
| 92 | + | if !isStreamingManifestResponse(resp) { | |||
| 93 | + | t.Fatal("isStreamingManifestResponse() = false, want true") | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) { | |||
| 98 | + | requestURL, err := url.Parse("http://10.0.0.1/private.mp4") | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 104 | + | if err == nil { | |||
| 105 | + | t.Fatal("validateTranscodeRedirect() expected error for private redirect target") | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |||
| 110 | + | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 116 | + | if err == nil { | |||
| 117 | + | t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target") | |||
| 118 | + | } | |||
| 119 | + | } | |||
M internal/infrastructure/transcoder/pipeline.go +108 -5
| @@ -5,13 +5,13 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | + | "net" | |||
| 8 | "net/http" | 9 | "net/http" | ||
| 10 | + | "net/url" | |||
| 9 | "sort" | 11 | "sort" | ||
| 10 | "strings" | 12 | "strings" | ||
| 11 | - | "unsafe" | |||
| 12 | - | ||||
| 13 | - | "net" | |||
| 14 | "time" | 13 | "time" | ||
| 14 | + | "unsafe" | |||
| 15 | 15 | ||||
| 16 | "github.com/bnema/purego" | 16 | "github.com/bnema/purego" | ||
| 17 | "github.com/rs/zerolog" | 17 | "github.com/rs/zerolog" | ||
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | ||
| 20 | 20 | ||||
| 21 | "github.com/bnema/dumber/internal/application/port" | 21 | "github.com/bnema/dumber/internal/application/port" | ||
| 22 | + | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 22 | ) | 23 | ) | ||
| 23 | 24 | ||||
| 24 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | 25 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | ||
| @@ -53,10 +54,104 @@ type pipeline struct { | |||||
| 53 | // are not killed. The session context handles cancellation. | 54 | // are not killed. The session context handles cancellation. | ||
| 54 | var httpClient = &http.Client{ | 55 | var httpClient = &http.Client{ | ||
| 55 | Transport: &http.Transport{ | 56 | Transport: &http.Transport{ | ||
| 56 | - | DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, | 57 | + | DialContext: safeTranscodeDialContext, |
| 57 | TLSHandshakeTimeout: 10 * time.Second, | 58 | TLSHandshakeTimeout: 10 * time.Second, | ||
| 58 | ResponseHeaderTimeout: 15 * time.Second, | 59 | ResponseHeaderTimeout: 15 * time.Second, | ||
| 59 | }, | 60 | }, | ||
| 61 | + | CheckRedirect: validateTranscodeRedirect, | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |||
| 65 | + | if len(via) >= 10 { | |||
| 66 | + | return fmt.Errorf("stopped after 10 redirects") | |||
| 67 | + | } | |||
| 68 | + | if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil { | |||
| 69 | + | return fmt.Errorf("blocked redirect target: %w", err) | |||
| 70 | + | } | |||
| 71 | + | return nil | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |||
| 75 | + | host, port, err := net.SplitHostPort(address) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return nil, err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | ips, err := resolveAllowedTranscodeIPs(ctx, host) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return nil, err | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | dialer := &net.Dialer{Timeout: 10 * time.Second} | |||
| 86 | + | var lastErr error | |||
| 87 | + | for _, ip := range ips { | |||
| 88 | + | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) | |||
| 89 | + | if dialErr == nil { | |||
| 90 | + | return conn, nil | |||
| 91 | + | } | |||
| 92 | + | lastErr = dialErr | |||
| 93 | + | } | |||
| 94 | + | if lastErr != nil { | |||
| 95 | + | return nil, lastErr | |||
| 96 | + | } | |||
| 97 | + | return nil, fmt.Errorf("no allowed transcode source addresses for %s", host) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error { | |||
| 101 | + | if source == nil || source.Host == "" { | |||
| 102 | + | return fmt.Errorf("invalid src") | |||
| 103 | + | } | |||
| 104 | + | if source.Scheme != "http" && source.Scheme != "https" { | |||
| 105 | + | return fmt.Errorf("unsupported src scheme") | |||
| 106 | + | } | |||
| 107 | + | if IsStreamingManifestURL(source.String()) { | |||
| 108 | + | return fmt.Errorf("streaming manifest transcoding is disabled") | |||
| 109 | + | } | |||
| 110 | + | _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname()) | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) { | |||
| 115 | + | host = strings.TrimSpace(host) | |||
| 116 | + | if host == "" { | |||
| 117 | + | return nil, fmt.Errorf("invalid src host") | |||
| 118 | + | } | |||
| 119 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 120 | + | if isBlockedTranscodeIP(ip) { | |||
| 121 | + | return nil, fmt.Errorf("private src host not allowed") | |||
| 122 | + | } | |||
| 123 | + | return []net.IP{ip}, nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 127 | + | defer cancel() | |||
| 128 | + | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 129 | + | if err != nil || len(addrs) == 0 { | |||
| 130 | + | return nil, fmt.Errorf("src host could not be resolved") | |||
| 131 | + | } | |||
| 132 | + | ips := make([]net.IP, 0, len(addrs)) | |||
| 133 | + | for _, addr := range addrs { | |||
| 134 | + | if isBlockedTranscodeIP(addr.IP) { | |||
| 135 | + | return nil, fmt.Errorf("private src host not allowed") | |||
| 136 | + | } | |||
| 137 | + | ips = append(ips, addr.IP) | |||
| 138 | + | } | |||
| 139 | + | return ips, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 143 | + | return netguard.IsBlockedTranscodeIP(ip) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func isStreamingManifestResponse(resp *http.Response) bool { | |||
| 147 | + | if resp == nil { | |||
| 148 | + | return false | |||
| 149 | + | } | |||
| 150 | + | if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) { | |||
| 151 | + | return true | |||
| 152 | + | } | |||
| 153 | + | contentType := strings.ToLower(resp.Header.Get("Content-Type")) | |||
| 154 | + | return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml") | |||
| 60 | } | 155 | } | ||
| 61 | 156 | ||||
| 62 | func newPipeline( | 157 | func newPipeline( | ||
| @@ -394,7 +489,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI | |||||
| 394 | 489 | ||||
| 395 | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | 490 | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | ||
| 396 | if IsStreamingManifestURL(p.sourceURL) { | 491 | if IsStreamingManifestURL(p.sourceURL) { | ||
| 397 | - | return p.openManifestInputContext() | 492 | + | // FFmpeg resolves manifest redirects and segment URLs internally, bypassing |
| 493 | + | // the guarded Go HTTP client below. Keep untrusted transcode requests on | |||
| 494 | + | // custom I/O until manifests can be fetched and rewritten through the same | |||
| 495 | + | // SSRF protections. | |||
| 496 | + | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 398 | } | 497 | } | ||
| 399 | return p.openCustomIOInputContext(ctx) | 498 | return p.openCustomIOInputContext(ctx) | ||
| 400 | } | 499 | } | ||
| @@ -417,6 +516,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||||
| 417 | _ = resp.Body.Close() | 516 | _ = resp.Body.Close() | ||
| 418 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | 517 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | ||
| 419 | } | 518 | } | ||
| 519 | + | if isStreamingManifestResponse(resp) { | |||
| 520 | + | _ = resp.Body.Close() | |||
| 521 | + | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 522 | + | } | |||
| 420 | 523 | ||||
| 421 | body := resp.Body | 524 | body := resp.Body | ||
| 422 | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | 525 | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | ||
M internal/infrastructure/webkit/scheme_handler.go +56 -17
| @@ -1,16 +1,19 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "embed" | 6 | "embed" | ||
| 6 | "fmt" | 7 | "fmt" | ||
| 8 | + | "io" | |||
| 7 | "io/fs" | 9 | "io/fs" | ||
| 8 | "net/http" | 10 | "net/http" | ||
| 9 | "net/url" | 11 | "net/url" | ||
| 10 | - | "path/filepath" | 12 | + | "path" |
| 11 | "strings" | 13 | "strings" | ||
| 12 | "sync" | 14 | "sync" | ||
| 13 | 15 | ||||
| 16 | + | "github.com/andybalholm/brotli" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 17 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 15 | "github.com/bnema/dumber/internal/logging" | 18 | "github.com/bnema/dumber/internal/logging" | ||
| 16 | "github.com/bnema/puregotk/v4/gio" | 19 | "github.com/bnema/puregotk/v4/gio" | ||
| @@ -21,13 +24,15 @@ import ( | |||||
| 21 | 24 | ||||
| 22 | // Scheme path constants | 25 | // Scheme path constants | ||
| 23 | const ( | 26 | const ( | ||
| 24 | - | HistoryPath = "history" | 27 | + | HistoryPath = "history" |
| 25 | - | FavoritesPath = "favorites" | 28 | + | FavoritesPath = "favorites" |
| 26 | - | ConfigPath = "config" | 29 | + | ConfigPath = "config" |
| 27 | - | ErrorPath = "error" | 30 | + | ErrorPath = "error" |
| 28 | - | CrashPath = "crash" | 31 | + | CrashPath = "crash" |
| 29 | - | IndexHTML = "index.html" | 32 | + | IndexHTML = "index.html" |
| 30 | - | httpGET = "GET" | 33 | + | httpGET = "GET" |
| 34 | + | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | |||
| 35 | + | systemviewsAssetDir = "systemviews" | |||
| 31 | ) | 36 | ) | ||
| 32 | 37 | ||||
| 33 | // SchemeRequest represents a request to a custom URI scheme. | 38 | // SchemeRequest represents a request to a custom URI scheme. | ||
| @@ -76,7 +81,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 76 | 81 | ||||
| 77 | h := &DumbSchemeHandler{ | 82 | h := &DumbSchemeHandler{ | ||
| 78 | handlers: make(map[string]PageHandler), | 83 | handlers: make(map[string]PageHandler), | ||
| 79 | - | assetDir: "systemviews", | 84 | + | assetDir: systemviewsAssetDir, |
| 80 | logger: log.With().Str("component", "scheme-handler").Logger(), | 85 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 81 | } | 86 | } | ||
| 82 | 87 | ||||
| @@ -300,7 +305,11 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 300 | assetDir = h.assetDir | 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 | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | 313 | data, headers, err := readAssetWithEncoding(h.assets, fullPath, relPath) | ||
| 305 | if err != nil { | 314 | if err != nil { | ||
| 306 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | 315 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | ||
| @@ -322,10 +331,40 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 322 | } | 331 | } | ||
| 323 | } | 332 | } | ||
| 324 | 333 | ||||
| 334 | + | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |||
| 335 | + | assetDir = strings.Trim(assetDir, "/") | |||
| 336 | + | if assetDir != systemviewsAssetDir { | |||
| 337 | + | return "", "", false | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | relPath = strings.TrimLeft(relPath, "/") | |||
| 341 | + | if relPath == "" || strings.ContainsRune(relPath, '\x00') { | |||
| 342 | + | return "", "", false | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | cleanRelPath = path.Clean(relPath) | |||
| 346 | + | if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) { | |||
| 347 | + | return "", "", false | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | fullPath = path.Join(assetDir, cleanRelPath) | |||
| 351 | + | if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") { | |||
| 352 | + | return "", "", false | |||
| 353 | + | } | |||
| 354 | + | return fullPath, cleanRelPath, true | |||
| 355 | + | } | |||
| 356 | + | ||||
| 325 | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | 357 | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) { | ||
| 326 | if strings.HasSuffix(relPath, ".wasm") { | 358 | if strings.HasSuffix(relPath, ".wasm") { | ||
| 327 | - | if data, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | 359 | + | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { |
| 328 | - | return data, map[string]string{"Content-Encoding": "br", "Vary": "Accept-Encoding"}, 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 | data, err := fs.ReadFile(assets, fullPath) | 370 | data, err := fs.ReadFile(assets, fullPath) | ||
| @@ -341,10 +380,10 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 341 | assetDir string | 380 | assetDir string | ||
| 342 | file string | 381 | file string | ||
| 343 | }{ | 382 | }{ | ||
| 344 | - | HistoryPath: {assetDir: "systemviews", file: IndexHTML}, | 383 | + | HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, |
| 345 | - | FavoritesPath: {assetDir: "systemviews", file: IndexHTML}, | 384 | + | FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, |
| 346 | - | ConfigPath: {assetDir: "systemviews", file: IndexHTML}, | 385 | + | ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, |
| 347 | - | ErrorPath: {assetDir: "systemviews", file: IndexHTML}, | 386 | + | ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, |
| 348 | } | 387 | } | ||
| 349 | 388 | ||||
| 350 | if root, ok := rootByHost[u.Host]; ok { | 389 | if root, ok := rootByHost[u.Host]; ok { | ||
| @@ -357,7 +396,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 357 | 396 | ||||
| 358 | switch u.Opaque { | 397 | switch u.Opaque { | ||
| 359 | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath: | 398 | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath: | ||
| 360 | - | return "systemviews", IndexHTML, true | 399 | + | return systemviewsAssetDir, IndexHTML, true |
| 361 | default: | 400 | default: | ||
| 362 | return "", "", false | 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 | resp := h.handleAsset(u) | 115 | resp := h.handleAsset(u) | ||
| 116 | require.NotNil(t, resp) | 116 | require.NotNil(t, resp) | ||
| 117 | + | // Charset suffixes are acceptable; assert the MIME type contract only. | |||
| 117 | assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0]) | 118 | assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0]) | ||
| 118 | assert.Contains(t, string(resp.Data), ".sv-app") | 119 | assert.Contains(t, string(resp.Data), ".sv-app") | ||
| 119 | } | 120 | } | ||
| @@ -213,3 +214,57 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||||
| 213 | assert.Equal(t, "*", headers["Access-Control-Allow-Origin"]) | 214 | assert.Equal(t, "*", headers["Access-Control-Allow-Origin"]) | ||
| 214 | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | 215 | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | ||
| 215 | } | 216 | } | ||
| 217 | + | ||||
| 218 | + | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | |||
| 219 | + | t.Parallel() | |||
| 220 | + | ||||
| 221 | + | data, headers, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | require.GreaterOrEqual(t, len(data), 4) | |||
| 224 | + | assert.Equal(t, "\x00asm", string(data[:4])) | |||
| 225 | + | assert.NotContains(t, headers, "Content-Encoding") | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | |||
| 229 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 230 | + | h.SetAssets(assets.WebUIAssets) | |||
| 231 | + | ||||
| 232 | + | for _, raw := range []string{ | |||
| 233 | + | "dumb://history/../logo.svg", | |||
| 234 | + | "dumb://history/nested/../../logo.svg", | |||
| 235 | + | } { | |||
| 236 | + | t.Run(raw, func(t *testing.T) { | |||
| 237 | + | u, err := url.Parse(raw) | |||
| 238 | + | require.NoError(t, err) | |||
| 239 | + | assert.Nil(t, h.handleAsset(u)) | |||
| 240 | + | }) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) { | |||
| 245 | + | t.Parallel() | |||
| 246 | + | ||||
| 247 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css") | |||
| 248 | + | require.True(t, ok) | |||
| 249 | + | assert.Equal(t, "systemviews/systemviews.css", fullPath) | |||
| 250 | + | assert.Equal(t, "systemviews.css", relPath) | |||
| 251 | + | ||||
| 252 | + | invalid := []struct { | |||
| 253 | + | name string | |||
| 254 | + | assetDir string | |||
| 255 | + | relPath string | |||
| 256 | + | }{ | |||
| 257 | + | {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"}, | |||
| 258 | + | {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"}, | |||
| 259 | + | {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"}, | |||
| 260 | + | {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"}, | |||
| 261 | + | {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"}, | |||
| 262 | + | } | |||
| 263 | + | for _, tt := range invalid { | |||
| 264 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 265 | + | t.Parallel() | |||
| 266 | + | _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath) | |||
| 267 | + | assert.False(t, ok) | |||
| 268 | + | }) | |||
| 269 | + | } | |||
| 270 | + | } | |||
M internal/infrastructure/webutil/mime.go +5 -1
| @@ -9,7 +9,11 @@ import ( | |||||
| 9 | // GetMimeType determines the MIME type for a given file path. | 9 | // GetMimeType determines the MIME type for a given file path. | ||
| 10 | // It tries the standard library first, then falls back to common web asset types. | 10 | // It tries the standard library first, then falls back to common web asset types. | ||
| 11 | func GetMimeType(filename string) string { | 11 | func GetMimeType(filename string) string { | ||
| 12 | - | ext := strings.ToLower(filepath.Ext(filename)) | 12 | + | lowerName := strings.ToLower(filename) |
| 13 | + | ext := filepath.Ext(lowerName) | |||
| 14 | + | if ext == ".br" { | |||
| 15 | + | return GetMimeType(strings.TrimSuffix(lowerName, ext)) | |||
| 16 | + | } | |||
| 13 | 17 | ||||
| 14 | mt := mime.TypeByExtension(ext) | 18 | mt := mime.TypeByExtension(ext) | ||
| 15 | if mt != "" { | 19 | if mt != "" { | ||
M internal/infrastructure/webutil/mime_test.go +23 -3
| @@ -2,10 +2,30 @@ package webutil | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | - | func TestGetMimeType_WASM(t *testing.T) { | 5 | + | func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) { |
| 6 | t.Parallel() | 6 | t.Parallel() | ||
| 7 | 7 | ||||
| 8 | - | if got := GetMimeType("systemviews.wasm"); got != "application/wasm" { | 8 | + | tests := []struct { |
| 9 | - | t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm") | 9 | + | name string |
| 10 | + | filename string | |||
| 11 | + | want string | |||
| 12 | + | }{ | |||
| 13 | + | {name: "path input", filename: "assets/systemviews/systemviews.wasm", want: "application/wasm"}, | |||
| 14 | + | {name: "uppercase extension", filename: "systemviews.WASM", want: "application/wasm"}, | |||
| 15 | + | {name: "brotli compressed wasm", filename: "systemviews.wasm.br", want: "application/wasm"}, | |||
| 16 | + | {name: "uppercase brotli compressed wasm", filename: "SYSTEMVIEWS.WASM.BR", want: "application/wasm"}, | |||
| 17 | + | {name: "plain brotli", filename: "data.br", want: "text/plain"}, | |||
| 18 | + | {name: "js brotli", filename: "app.js.br", want: "text/javascript; charset=utf-8"}, | |||
| 19 | + | {name: "tar brotli", filename: "archive.tar.br", want: "application/x-tar"}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | for _, tt := range tests { | |||
| 23 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 24 | + | t.Parallel() | |||
| 25 | + | ||||
| 26 | + | if got := GetMimeType(tt.filename); got != tt.want { | |||
| 27 | + | t.Fatalf("GetMimeType(%s) = %q, want %q", tt.filename, got, tt.want) | |||
| 28 | + | } | |||
| 29 | + | }) | |||
| 10 | } | 30 | } | ||
| 11 | } | 31 | } | ||
M internal/ui/coordinator/workspace.go +19 -3
| @@ -231,13 +231,13 @@ func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, target | |||||
| 231 | } | 231 | } | ||
| 232 | if c.getActiveWS == nil { | 232 | if c.getActiveWS == nil { | ||
| 233 | log.Warn().Str("url", targetURL).Msg("active workspace provider not configured") | 233 | log.Warn().Str("url", targetURL).Msg("active workspace provider not configured") | ||
| 234 | - | return nil | 234 | + | return fmt.Errorf("system view toggle: active workspace provider not configured") |
| 235 | } | 235 | } | ||
| 236 | 236 | ||||
| 237 | ws, wsView := c.getActiveWS() | 237 | ws, wsView := c.getActiveWS() | ||
| 238 | if ws == nil { | 238 | if ws == nil { | ||
| 239 | log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle") | 239 | log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle") | ||
| 240 | - | return nil | 240 | + | return fmt.Errorf("system view toggle: no active workspace") |
| 241 | } | 241 | } | ||
| 242 | 242 | ||||
| 243 | activePane := ws.ActivePane() | 243 | activePane := ws.ActivePane() | ||
| @@ -284,6 +284,10 @@ func (c *WorkspaceCoordinator) focusExistingPane( | |||||
| 284 | ) { | 284 | ) { | ||
| 285 | oldActivePaneID := ws.ActivePaneID | 285 | oldActivePaneID := ws.ActivePaneID | ||
| 286 | ws.ActivePaneID = paneID | 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 | if wsView != nil { | 292 | if wsView != nil { | ||
| 289 | wsView.CancelAllPendingHovers() | 293 | wsView.CancelAllPendingHovers() | ||
| @@ -296,7 +300,7 @@ func (c *WorkspaceCoordinator) focusExistingPane( | |||||
| 296 | } else { | 300 | } else { | ||
| 297 | wsView.FocusPane(paneID) | 301 | wsView.FocusPane(paneID) | ||
| 298 | } | 302 | } | ||
| 299 | - | if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { | 303 | + | if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { |
| 300 | c.syncStackedViewActive(ctx, wsView, paneNode) | 304 | c.syncStackedViewActive(ctx, wsView, paneNode) | ||
| 301 | } | 305 | } | ||
| 302 | } | 306 | } | ||
| @@ -304,6 +308,18 @@ func (c *WorkspaceCoordinator) focusExistingPane( | |||||
| 304 | c.notifyStateChanged() | 308 | c.notifyStateChanged() | ||
| 305 | } | 309 | } | ||
| 306 | 310 | ||||
| 311 | + | func setActiveStackIndexForChild(parent, child *entity.PaneNode) { | |||
| 312 | + | if parent == nil || child == nil { | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | for i, candidate := range parent.Children { | |||
| 316 | + | if candidate == child { | |||
| 317 | + | parent.ActiveStackIndex = i | |||
| 318 | + | return | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 307 | func (c *WorkspaceCoordinator) prepareSplit( | 323 | func (c *WorkspaceCoordinator) prepareSplit( | ||
| 308 | ctx context.Context, | 324 | ctx context.Context, | ||
| 309 | direction usecase.SplitDirection, | 325 | direction usecase.SplitDirection, | ||
M internal/ui/coordinator/workspace_systemview_test.go +33 -0
| @@ -53,6 +53,10 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testin | |||||
| 53 | ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID} | 53 | ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID} | ||
| 54 | 54 | ||||
| 55 | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | 55 | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | ||
| 56 | + | PanesUC: usecase.NewManagePanesUseCase(func() string { | |||
| 57 | + | t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane") | |||
| 58 | + | return "unused" | |||
| 59 | + | }), | |||
| 56 | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | 60 | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | ||
| 57 | return ws, nil | 61 | return ws, nil | ||
| 58 | }, | 62 | }, | ||
| @@ -91,3 +95,32 @@ func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T | |||||
| 91 | require.NotNil(t, active) | 95 | require.NotNil(t, active) | ||
| 92 | assert.Equal(t, "https://example.com", active.Pane.URI) | 96 | assert.Equal(t, "https://example.com", active.Pane.URI) | ||
| 93 | } | 97 | } | ||
| 98 | + | ||||
| 99 | + | func TestWorkspaceCoordinator_ToggleSystemViewRightUpdatesStackIndexForExistingPane(t *testing.T) { | |||
| 100 | + | ctx := context.Background() | |||
| 101 | + | first := testLeafNode("pane-1") | |||
| 102 | + | first.Pane.URI = "https://example.com" | |||
| 103 | + | second := testLeafNode("pane-2") | |||
| 104 | + | second.Pane.URI = "dumb://history" | |||
| 105 | + | stack := &entity.PaneNode{ | |||
| 106 | + | ID: "stack-1", | |||
| 107 | + | IsStacked: true, | |||
| 108 | + | ActiveStackIndex: 0, | |||
| 109 | + | Children: []*entity.PaneNode{first, second}, | |||
| 110 | + | } | |||
| 111 | + | first.Parent = stack | |||
| 112 | + | second.Parent = stack | |||
| 113 | + | ws := &entity.Workspace{ID: "ws-1", Root: stack, ActivePaneID: first.Pane.ID} | |||
| 114 | + | ||||
| 115 | + | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |||
| 116 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |||
| 117 | + | return ws, nil | |||
| 118 | + | }, | |||
| 119 | + | }) | |||
| 120 | + | ||||
| 121 | + | err := coord.ToggleSystemViewRight(ctx, "dumb://history") | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | ||||
| 124 | + | assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID) | |||
| 125 | + | assert.Equal(t, 1, stack.ActiveStackIndex) | |||
| 126 | + | } | |||
M internal/ui/input/global_shortcuts.go +24 -29
| @@ -104,21 +104,7 @@ func NewGlobalShortcutHandler( | |||||
| 104 | Msg("registered global shortcut") | 104 | Msg("registered global shortcut") | ||
| 105 | 105 | ||||
| 106 | if workspace != nil { | 106 | if workspace != nil { | ||
| 107 | - | // Map action names to action constants for global shortcuts | 107 | + | actionMap := globalShortcutActionMap() |
| 108 | - | actionMap := map[string]Action{ | |||
| 109 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 110 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 111 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 112 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 113 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 114 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 115 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 116 | - | "toggle-history-systemview": ActionToggleHistorySystemView, | |||
| 117 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |||
| 118 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |||
| 119 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 120 | - | "toggle-config-systemview": ActionToggleConfigSystemView, | |||
| 121 | - | } | |||
| 122 | 108 | ||||
| 123 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | 109 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | ||
| 124 | action, ok := actionMap[actionName] | 110 | action, ok := actionMap[actionName] | ||
| @@ -253,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||||
| 253 | return true | 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 | // ReloadShortcuts rebuilds global shortcuts from a new config. | 264 | // ReloadShortcuts rebuilds global shortcuts from a new config. | ||
| 257 | // It replaces the GTK shortcut controller to ensure stale shortcuts | 265 | // It replaces the GTK shortcut controller to ensure stale shortcuts | ||
| 258 | // are removed. | 266 | // are removed. | ||
| @@ -291,20 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||||
| 291 | 299 | ||||
| 292 | // Re-register config-driven shortcuts | 300 | // Re-register config-driven shortcuts | ||
| 293 | if workspace != nil { | 301 | if workspace != nil { | ||
| 294 | - | actionMap := map[string]Action{ | 302 | + | actionMap := globalShortcutActionMap() |
| 295 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 296 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 297 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 298 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 299 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 300 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 301 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 302 | - | "toggle-history-systemview": ActionToggleHistorySystemView, | |||
| 303 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |||
| 304 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |||
| 305 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 306 | - | "toggle-config-systemview": ActionToggleConfigSystemView, | |||
| 307 | - | } | |||
| 308 | 303 | ||||
| 309 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | 304 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | ||
| 310 | action, ok := actionMap[actionName] | 305 | action, ok := actionMap[actionName] | ||
M internal/ui/input/shortcuts.go +19 -28
| @@ -362,35 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 362 | // r = rename tab | 362 | // r = rename tab | ||
| 363 | // This follows Zellij-style modal keyboard interface. | 363 | // This follows Zellij-style modal keyboard interface. | ||
| 364 | // | 364 | // | ||
| 365 | - | // However, these standard browser shortcuts ARE global: | 365 | + | // However, these standard browser shortcuts ARE global. |
| 366 | - | // Map action names to action constants | |||
| 367 | - | actionMap := map[string]Action{ | |||
| 368 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 369 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 370 | - | "close_pane": ActionClosePane, | |||
| 371 | - | "close-pane": ActionClosePane, | |||
| 372 | - | "next_tab": ActionNextTab, | |||
| 373 | - | "next-tab": ActionNextTab, | |||
| 374 | - | "previous_tab": ActionPreviousTab, | |||
| 375 | - | "previous-tab": ActionPreviousTab, | |||
| 376 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 377 | - | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |||
| 378 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 379 | - | "consume-or-expel-right": ActionConsumeOrExpelRight, | |||
| 380 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 381 | - | "consume-or-expel-up": ActionConsumeOrExpelUp, | |||
| 382 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 383 | - | "consume-or-expel-down": ActionConsumeOrExpelDown, | |||
| 384 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 385 | - | "toggle-history-systemview": ActionToggleHistorySystemView, | |||
| 386 | - | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |||
| 387 | - | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |||
| 388 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 389 | - | "toggle-config-systemview": ActionToggleConfigSystemView, | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | for actionName, actionBinding := range cfg.Shortcuts.Actions { | 366 | for actionName, actionBinding := range cfg.Shortcuts.Actions { | ||
| 393 | - | action, ok := actionMap[actionName] | 367 | + | action, ok := configActionToAction[actionName] |
| 394 | if !ok { | 368 | if !ok { | ||
| 395 | continue | 369 | continue | ||
| 396 | } | 370 | } | ||
| @@ -599,25 +573,42 @@ var configActionToAction = map[string]Action{ | |||||
| 599 | "toggle-config-systemview": ActionToggleConfigSystemView, | 573 | "toggle-config-systemview": ActionToggleConfigSystemView, | ||
| 600 | 574 | ||||
| 601 | // Tab actions | 575 | // Tab actions | ||
| 576 | + | "new_tab": ActionNewTab, | |||
| 602 | "new-tab": ActionNewTab, | 577 | "new-tab": ActionNewTab, | ||
| 578 | + | "close_tab": ActionCloseTab, | |||
| 603 | "close-tab": ActionCloseTab, | 579 | "close-tab": ActionCloseTab, | ||
| 580 | + | "next_tab": ActionNextTab, | |||
| 604 | "next-tab": ActionNextTab, | 581 | "next-tab": ActionNextTab, | ||
| 582 | + | "previous_tab": ActionPreviousTab, | |||
| 605 | "previous-tab": ActionPreviousTab, | 583 | "previous-tab": ActionPreviousTab, | ||
| 584 | + | "rename_tab": ActionRenameTab, | |||
| 606 | "rename-tab": ActionRenameTab, | 585 | "rename-tab": ActionRenameTab, | ||
| 607 | 586 | ||||
| 608 | // Pane actions | 587 | // Pane actions | ||
| 588 | + | "split_right": ActionSplitRight, | |||
| 609 | "split-right": ActionSplitRight, | 589 | "split-right": ActionSplitRight, | ||
| 590 | + | "split_left": ActionSplitLeft, | |||
| 610 | "split-left": ActionSplitLeft, | 591 | "split-left": ActionSplitLeft, | ||
| 592 | + | "split_up": ActionSplitUp, | |||
| 611 | "split-up": ActionSplitUp, | 593 | "split-up": ActionSplitUp, | ||
| 594 | + | "split_down": ActionSplitDown, | |||
| 612 | "split-down": ActionSplitDown, | 595 | "split-down": ActionSplitDown, | ||
| 596 | + | "close_pane": ActionClosePane, | |||
| 613 | "close-pane": ActionClosePane, | 597 | "close-pane": ActionClosePane, | ||
| 598 | + | "stack_pane": ActionStackPane, | |||
| 614 | "stack-pane": ActionStackPane, | 599 | "stack-pane": ActionStackPane, | ||
| 600 | + | "move_pane_to_tab": ActionMovePaneToTab, | |||
| 615 | "move-pane-to-tab": ActionMovePaneToTab, | 601 | "move-pane-to-tab": ActionMovePaneToTab, | ||
| 602 | + | "move_pane_to_next_tab": ActionMovePaneToNextTab, | |||
| 616 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | 603 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | ||
| 617 | 604 | ||||
| 605 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 618 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | 606 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | ||
| 607 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 619 | "consume-or-expel-right": ActionConsumeOrExpelRight, | 608 | "consume-or-expel-right": ActionConsumeOrExpelRight, | ||
| 609 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 620 | "consume-or-expel-up": ActionConsumeOrExpelUp, | 610 | "consume-or-expel-up": ActionConsumeOrExpelUp, | ||
| 611 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 621 | "consume-or-expel-down": ActionConsumeOrExpelDown, | 612 | "consume-or-expel-down": ActionConsumeOrExpelDown, | ||
| 622 | 613 | ||||
| 623 | // Focus navigation | 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 | func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) { | 424 | func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) { | ||
| 406 | action := mapConfigAction("toggle-floating-pane") | 425 | action := mapConfigAction("toggle-floating-pane") | ||
| 407 | if action != ActionToggleFloatingPane { | 426 | if action != ActionToggleFloatingPane { | ||
| 408 | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) | 427 | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) | ||
| 409 | } | 428 | } | ||
| 410 | } | 429 | } | ||
| 430 | + | ||||
| 431 | + | func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) { | |||
| 432 | + | actions := globalShortcutActionMap() | |||
| 433 | + | tests := []struct { | |||
| 434 | + | name string | |||
| 435 | + | want Action | |||
| 436 | + | }{ | |||
| 437 | + | {name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft}, | |||
| 438 | + | {name: "consume-or-expel-right", want: ActionConsumeOrExpelRight}, | |||
| 439 | + | {name: "consume-or-expel-up", want: ActionConsumeOrExpelUp}, | |||
| 440 | + | {name: "consume-or-expel-down", want: ActionConsumeOrExpelDown}, | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | for _, tt := range tests { | |||
| 444 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 445 | + | if got := actions[tt.name]; got != tt.want { | |||
| 446 | + | t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want) | |||
| 447 | + | } | |||
| 448 | + | }) | |||
| 449 | + | } | |||
| 450 | + | } | |||
A internal/ui/systemviews/action_log.go +20 -0
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func logActionMountError(ctx context.Context, mountErr, actionErr error) { | |||
| 12 | + | if mountErr == nil && actionErr == nil { | |||
| 13 | + | return | |||
| 14 | + | } | |||
| 15 | + | logging.FromContext(ctx). | |||
| 16 | + | Error(). | |||
| 17 | + | Err(mountErr). | |||
| 18 | + | Str("action_error", errorString(actionErr)). | |||
| 19 | + | Msg("failed to mount systemview action error") | |||
| 20 | + | } | |||
A internal/ui/systemviews/action_log_common.go +8 -0
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | func errorString(err error) string { | |||
| 4 | + | if err == nil { | |||
| 5 | + | return "<nil>" | |||
| 6 | + | } | |||
| 7 | + | return err.Error() | |||
| 8 | + | } | |||
A internal/ui/systemviews/action_log_js.go +19 -0
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "syscall/js" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func logActionMountError(_ context.Context, mountErr, actionErr error) { | |||
| 11 | + | if mountErr == nil && actionErr == nil { | |||
| 12 | + | return | |||
| 13 | + | } | |||
| 14 | + | console := js.Global().Get("console") | |||
| 15 | + | if !console.Truthy() || !console.Get("error").Truthy() { | |||
| 16 | + | return | |||
| 17 | + | } | |||
| 18 | + | console.Call("error", "failed to mount systemview action error", errorString(mountErr), errorString(actionErr)) | |||
| 19 | + | } | |||
M internal/ui/systemviews/app.go +219 -51
| @@ -20,6 +20,8 @@ type Dependencies struct { | |||||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | type App struct { | 22 | type App struct { | ||
| 23 | + | // mu protects rendered route state; actionMu protects worker channels. | |||
| 24 | + | // The two locks must not be held at the same time. | |||
| 23 | mu sync.Mutex | 25 | mu sync.Mutex | ||
| 24 | actionMu sync.Mutex | 26 | actionMu sync.Mutex | ||
| 25 | deps Dependencies | 27 | deps Dependencies | ||
| @@ -46,12 +48,33 @@ type App struct { | |||||
| 46 | configError string | 48 | configError string | ||
| 47 | renderedHTML string | 49 | renderedHTML string | ||
| 48 | actionQueue chan DOMAction | 50 | actionQueue chan DOMAction | ||
| 51 | + | actionErrorQueue chan error | |||
| 49 | actionCtx context.Context | 52 | actionCtx context.Context | ||
| 50 | actionCancel context.CancelFunc | 53 | actionCancel context.CancelFunc | ||
| 54 | + | actionClosed bool | |||
| 55 | + | actionWG sync.WaitGroup | |||
| 51 | } | 56 | } | ||
| 52 | 57 | ||||
| 53 | const historyTimelineLimit = 25 | 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 | func NewApp(deps Dependencies) *App { | 78 | func NewApp(deps Dependencies) *App { | ||
| 56 | return &App{deps: deps, currentRoute: RouteUnknown} | 79 | return &App{deps: deps, currentRoute: RouteUnknown} | ||
| 57 | } | 80 | } | ||
| @@ -65,16 +88,16 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||||
| 65 | return errors.New("app is nil") | 88 | return errors.New("app is nil") | ||
| 66 | } | 89 | } | ||
| 67 | if ctx == nil { | 90 | if ctx == nil { | ||
| 68 | - | ctx = context.Background() | 91 | + | return errors.New("context is nil") |
| 69 | } | 92 | } | ||
| 70 | 93 | ||||
| 71 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 94 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 72 | - | if err := a.LoadInitial(ctx); err != nil { | |||
| 73 | - | return err | |||
| 74 | - | } | |||
| 75 | if a.deps.DOM == nil { | 95 | if a.deps.DOM == nil { | ||
| 76 | return errors.New("DOM not configured") | 96 | return errors.New("DOM not configured") | ||
| 77 | } | 97 | } | ||
| 98 | + | if err := a.LoadInitial(ctx); err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 78 | 101 | ||||
| 79 | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { | 102 | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { | ||
| 80 | return err | 103 | return err | ||
| @@ -86,43 +109,114 @@ func (a *App) RunWithContext(ctx context.Context) error { | |||||
| 86 | } | 109 | } | ||
| 87 | 110 | ||||
| 88 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | 111 | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | ||
| 89 | - | a.actionMu.Lock() | 112 | + | var ( |
| 113 | + | created bool | |||
| 114 | + | workerCtx context.Context | |||
| 115 | + | cancel context.CancelFunc | |||
| 116 | + | queue chan DOMAction | |||
| 117 | + | errorQueue chan error | |||
| 118 | + | ) | |||
| 119 | + | ||||
| 120 | + | a.lockAction() | |||
| 121 | + | if a.actionClosed { | |||
| 122 | + | a.unlockAction() | |||
| 123 | + | return errors.New("systemview app is closed") | |||
| 124 | + | } | |||
| 90 | if a.actionQueue == nil { | 125 | if a.actionQueue == nil { | ||
| 91 | - | workerCtx, cancel := context.WithCancel(ctx) | 126 | + | workerCtx, cancel = context.WithCancel(ctx) |
| 92 | - | queue := make(chan DOMAction, 64) | 127 | + | queue = make(chan DOMAction, 64) |
| 128 | + | errorQueue = make(chan error, 1) | |||
| 93 | a.actionQueue = queue | 129 | a.actionQueue = queue | ||
| 130 | + | a.actionErrorQueue = errorQueue | |||
| 94 | a.actionCtx = workerCtx | 131 | a.actionCtx = workerCtx | ||
| 95 | a.actionCancel = cancel | 132 | a.actionCancel = cancel | ||
| 96 | - | go a.runActionWorker(workerCtx, queue) | 133 | + | created = true |
| 97 | } | 134 | } | ||
| 98 | - | a.actionMu.Unlock() | 135 | + | a.unlockAction() |
| 99 | - | return binder.BindActions(func(action DOMAction) { | 136 | + | |
| 137 | + | if err := binder.BindActions(func(action DOMAction) { | |||
| 100 | if !a.enqueueDOMAction(action) && a.actionWorkerActive() { | 138 | if !a.enqueueDOMAction(action) && a.actionWorkerActive() { | ||
| 101 | - | go a.surfaceActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action)) | 139 | + | a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action)) |
| 140 | + | } | |||
| 141 | + | }); err != nil { | |||
| 142 | + | if created { | |||
| 143 | + | a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue) | |||
| 144 | + | } | |||
| 145 | + | return err | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | if created && !a.startActionWorkers(workerCtx, queue, errorQueue) { | |||
| 149 | + | a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue) | |||
| 150 | + | a.releaseDOMBindings() | |||
| 151 | + | return errors.New("systemview action worker unavailable") | |||
| 152 | + | } | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool { | |||
| 157 | + | if a == nil || ctx == nil || queue == nil || errorQueue == nil { | |||
| 158 | + | return false | |||
| 159 | + | } | |||
| 160 | + | a.lockAction() | |||
| 161 | + | ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil | |||
| 162 | + | if ready { | |||
| 163 | + | a.actionWG.Add(2) | |||
| 164 | + | } | |||
| 165 | + | a.unlockAction() | |||
| 166 | + | if !ready { | |||
| 167 | + | return false | |||
| 168 | + | } | |||
| 169 | + | go func() { | |||
| 170 | + | defer a.actionWG.Done() | |||
| 171 | + | a.runActionWorker(ctx, queue) | |||
| 172 | + | }() | |||
| 173 | + | go func() { | |||
| 174 | + | defer a.actionWG.Done() | |||
| 175 | + | a.runActionErrorWorker(ctx, errorQueue) | |||
| 176 | + | }() | |||
| 177 | + | return true | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) { | |||
| 181 | + | if a == nil { | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | a.lockAction() | |||
| 185 | + | if a.actionCtx == ctx && a.actionQueue == queue { | |||
| 186 | + | a.actionCancel = nil | |||
| 187 | + | a.actionCtx = nil | |||
| 188 | + | a.actionQueue = nil | |||
| 189 | + | a.actionErrorQueue = nil | |||
| 190 | + | if cancel != nil { | |||
| 191 | + | cancel() | |||
| 102 | } | 192 | } | ||
| 103 | - | }) | 193 | + | close(queue) |
| 194 | + | close(errorQueue) | |||
| 195 | + | } | |||
| 196 | + | a.unlockAction() | |||
| 104 | } | 197 | } | ||
| 105 | 198 | ||||
| 106 | func (a *App) actionWorkerActive() bool { | 199 | func (a *App) actionWorkerActive() bool { | ||
| 107 | if a == nil { | 200 | if a == nil { | ||
| 108 | return false | 201 | return false | ||
| 109 | } | 202 | } | ||
| 110 | - | a.actionMu.Lock() | 203 | + | a.lockAction() |
| 204 | + | closed := a.actionClosed | |||
| 111 | queue := a.actionQueue | 205 | queue := a.actionQueue | ||
| 112 | ctx := a.actionCtx | 206 | ctx := a.actionCtx | ||
| 113 | - | a.actionMu.Unlock() | 207 | + | a.unlockAction() |
| 114 | - | return queue != nil && ctx != nil && ctx.Err() == nil | 208 | + | return !closed && queue != nil && ctx != nil && ctx.Err() == nil |
| 115 | } | 209 | } | ||
| 116 | 210 | ||||
| 117 | func (a *App) enqueueDOMAction(action DOMAction) bool { | 211 | func (a *App) enqueueDOMAction(action DOMAction) bool { | ||
| 118 | if a == nil { | 212 | if a == nil { | ||
| 119 | return false | 213 | return false | ||
| 120 | } | 214 | } | ||
| 121 | - | a.actionMu.Lock() | 215 | + | a.lockAction() |
| 216 | + | defer a.unlockAction() | |||
| 122 | queue := a.actionQueue | 217 | queue := a.actionQueue | ||
| 123 | ctx := a.actionCtx | 218 | ctx := a.actionCtx | ||
| 124 | - | a.actionMu.Unlock() | 219 | + | if a.actionClosed || queue == nil || ctx == nil { |
| 125 | - | if queue == nil || ctx == nil { | |||
| 126 | return false | 220 | return false | ||
| 127 | } | 221 | } | ||
| 128 | select { | 222 | select { | ||
| @@ -135,45 +229,100 @@ func (a *App) enqueueDOMAction(action DOMAction) bool { | |||||
| 135 | } | 229 | } | ||
| 136 | } | 230 | } | ||
| 137 | 231 | ||||
| 232 | + | func (a *App) enqueueActionError(err error) { | |||
| 233 | + | if a == nil || err == nil { | |||
| 234 | + | return | |||
| 235 | + | } | |||
| 236 | + | a.lockAction() | |||
| 237 | + | defer a.unlockAction() | |||
| 238 | + | queue := a.actionErrorQueue | |||
| 239 | + | ctx := a.actionCtx | |||
| 240 | + | if a.actionClosed || queue == nil || ctx == nil { | |||
| 241 | + | return | |||
| 242 | + | } | |||
| 243 | + | select { | |||
| 244 | + | case <-ctx.Done(): | |||
| 245 | + | return | |||
| 246 | + | case queue <- err: | |||
| 247 | + | default: | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 138 | func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) { | 251 | func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) { | ||
| 139 | for { | 252 | for { | ||
| 140 | select { | 253 | select { | ||
| 141 | case <-ctx.Done(): | 254 | case <-ctx.Done(): | ||
| 142 | return | 255 | return | ||
| 143 | case action, ok := <-queue: | 256 | case action, ok := <-queue: | ||
| 144 | - | if !ok { | 257 | + | if !ok || ctx.Err() != nil { |
| 145 | return | 258 | return | ||
| 146 | } | 259 | } | ||
| 147 | if err := a.HandleDOMAction(ctx, action); err != nil { | 260 | if err := a.HandleDOMAction(ctx, action); err != nil { | ||
| 148 | - | a.surfaceActionError(err) | 261 | + | a.enqueueActionError(err) |
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) { | |||
| 268 | + | for { | |||
| 269 | + | select { | |||
| 270 | + | case <-ctx.Done(): | |||
| 271 | + | return | |||
| 272 | + | case err, ok := <-queue: | |||
| 273 | + | if !ok || ctx.Err() != nil { | |||
| 274 | + | return | |||
| 149 | } | 275 | } | ||
| 276 | + | a.surfaceActionError(ctx, err) | |||
| 150 | } | 277 | } | ||
| 151 | } | 278 | } | ||
| 152 | } | 279 | } | ||
| 153 | 280 | ||||
| 154 | - | func (a *App) surfaceActionError(err error) { | 281 | + | func (a *App) surfaceActionError(ctx context.Context, err error) { |
| 155 | if a == nil || err == nil { | 282 | if a == nil || err == nil { | ||
| 156 | return | 283 | return | ||
| 157 | } | 284 | } | ||
| 158 | - | a.mu.Lock() | 285 | + | a.lockState() |
| 159 | a.renderRouteError(err) | 286 | a.renderRouteError(err) | ||
| 160 | - | a.mu.Unlock() | 287 | + | html := a.renderedHTML |
| 161 | - | _ = a.mountRenderedHTML() | 288 | + | a.unlockState() |
| 289 | + | if mountErr := a.mountHTML(html); mountErr != nil { | |||
| 290 | + | logActionMountError(ctx, mountErr, err) | |||
| 291 | + | } | |||
| 162 | } | 292 | } | ||
| 163 | 293 | ||||
| 164 | func (a *App) Close() { | 294 | func (a *App) Close() { | ||
| 165 | if a == nil { | 295 | if a == nil { | ||
| 166 | return | 296 | return | ||
| 167 | } | 297 | } | ||
| 168 | - | a.actionMu.Lock() | 298 | + | a.lockAction() |
| 169 | cancel := a.actionCancel | 299 | cancel := a.actionCancel | ||
| 300 | + | actionQueue := a.actionQueue | |||
| 301 | + | actionErrorQueue := a.actionErrorQueue | |||
| 302 | + | a.actionClosed = true | |||
| 170 | a.actionCancel = nil | 303 | a.actionCancel = nil | ||
| 171 | a.actionCtx = nil | 304 | a.actionCtx = nil | ||
| 172 | a.actionQueue = nil | 305 | a.actionQueue = nil | ||
| 173 | - | a.actionMu.Unlock() | 306 | + | a.actionErrorQueue = nil |
| 307 | + | a.unlockAction() | |||
| 308 | + | ||||
| 174 | if cancel != nil { | 309 | if cancel != nil { | ||
| 175 | cancel() | 310 | cancel() | ||
| 176 | } | 311 | } | ||
| 312 | + | a.actionWG.Wait() | |||
| 313 | + | if actionQueue != nil { | |||
| 314 | + | close(actionQueue) | |||
| 315 | + | } | |||
| 316 | + | if actionErrorQueue != nil { | |||
| 317 | + | close(actionErrorQueue) | |||
| 318 | + | } | |||
| 319 | + | a.releaseDOMBindings() | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | func (a *App) releaseDOMBindings() { | |||
| 323 | + | if a == nil { | |||
| 324 | + | return | |||
| 325 | + | } | |||
| 177 | if releaser, ok := a.deps.DOM.(interface{ Release() }); ok { | 326 | if releaser, ok := a.deps.DOM.(interface{ Release() }); ok { | ||
| 178 | releaser.Release() | 327 | releaser.Release() | ||
| 179 | } | 328 | } | ||
| @@ -200,6 +349,7 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 200 | a.resetRouteState() | 349 | a.resetRouteState() | ||
| 201 | a.renderedHTML = renderAppFrame(renderedPage{ | 350 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 202 | route: a.currentRoute, | 351 | route: a.currentRoute, | ||
| 352 | + | title: routeDocumentTitle(a.currentRoute), | |||
| 203 | subtitle: string(a.currentRoute), | 353 | subtitle: string(a.currentRoute), | ||
| 204 | body: placeholderHTML(a.currentRoute), | 354 | body: placeholderHTML(a.currentRoute), | ||
| 205 | }, a.shellTheme) | 355 | }, a.shellTheme) | ||
| @@ -219,11 +369,25 @@ func (a *App) renderRouteError(err error) { | |||||
| 219 | } | 369 | } | ||
| 220 | a.renderedHTML = renderAppFrame(renderedPage{ | 370 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 221 | route: a.currentRoute, | 371 | route: a.currentRoute, | ||
| 372 | + | title: routeDocumentTitle(a.currentRoute) + " — Error", | |||
| 222 | subtitle: routeSubtitle(a.currentRoute), | 373 | subtitle: routeSubtitle(a.currentRoute), | ||
| 223 | body: errorStateHTML(message), | 374 | body: errorStateHTML(message), | ||
| 224 | }, a.shellTheme) | 375 | }, a.shellTheme) | ||
| 225 | } | 376 | } | ||
| 226 | 377 | ||||
| 378 | + | func routeDocumentTitle(route Route) string { | |||
| 379 | + | switch route { | |||
| 380 | + | case RouteHistory: | |||
| 381 | + | return "History" | |||
| 382 | + | case RouteFavorites: | |||
| 383 | + | return "Favorites" | |||
| 384 | + | case RouteConfig: | |||
| 385 | + | return "Config" | |||
| 386 | + | default: | |||
| 387 | + | return "Dumber System View" | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 227 | func routeSubtitle(route Route) string { | 391 | func routeSubtitle(route Route) string { | ||
| 228 | switch route { | 392 | switch route { | ||
| 229 | case RouteHistory: | 393 | case RouteHistory: | ||
| @@ -242,6 +406,7 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 242 | a.resetRouteState() | 406 | a.resetRouteState() | ||
| 243 | a.renderedHTML = renderAppFrame(renderedPage{ | 407 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 244 | route: a.currentRoute, | 408 | route: a.currentRoute, | ||
| 409 | + | title: routeDocumentTitle(RouteHistory), | |||
| 245 | subtitle: "Recent visits", | 410 | subtitle: "Recent visits", | ||
| 246 | body: placeholderHTML(a.currentRoute), | 411 | body: placeholderHTML(a.currentRoute), | ||
| 247 | }, a.shellTheme) | 412 | }, a.shellTheme) | ||
| @@ -266,20 +431,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 266 | a.historyEntries = entries | 431 | a.historyEntries = entries | ||
| 267 | a.historyAnalytics = analytics | 432 | a.historyAnalytics = analytics | ||
| 268 | a.historyDomainStats = domains | 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 | a.renderedHTML = renderAppFrame(renderedPage{ | 445 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 270 | route: RouteHistory, | 446 | route: RouteHistory, | ||
| 447 | + | title: historyDocumentTitle(data), | |||
| 271 | subtitle: "Recent visits", | 448 | subtitle: "Recent visits", | ||
| 272 | - | body: historyHTML(historyRenderData{ | 449 | + | body: historyHTML(data), |
| 273 | - | Entries: entries, | |||
| 274 | - | Analytics: analytics, | |||
| 275 | - | Domains: domains, | |||
| 276 | - | Query: a.historyQuery, | |||
| 277 | - | DomainFilter: a.historyDomainFilter, | |||
| 278 | - | Offset: a.historyOffset, | |||
| 279 | - | Limit: historyTimelineLimit, | |||
| 280 | - | Notice: a.historyNotice, | |||
| 281 | - | Error: a.historyError, | |||
| 282 | - | }), | |||
| 283 | }, a.shellTheme) | 450 | }, a.shellTheme) | ||
| 284 | return nil | 451 | return nil | ||
| 285 | } | 452 | } | ||
| @@ -288,11 +455,7 @@ func (a *App) loadHistoryEntries(ctx context.Context) ([]*entity.HistoryEntry, e | |||||
| 288 | query := strings.TrimSpace(a.historyQuery) | 455 | query := strings.TrimSpace(a.historyQuery) | ||
| 289 | domain := strings.TrimSpace(a.historyDomainFilter) | 456 | domain := strings.TrimSpace(a.historyDomainFilter) | ||
| 290 | if query != "" { | 457 | if query != "" { | ||
| 291 | - | entries, err := a.deps.History.Search(ctx, query, historyTimelineLimit) | 458 | + | return a.deps.History.Search(ctx, query, historyTimelineLimit) |
| 292 | - | if err != nil || domain == "" { | |||
| 293 | - | return entries, err | |||
| 294 | - | } | |||
| 295 | - | return filterHistoryEntriesByDomain(entries, domain), nil | |||
| 296 | } | 459 | } | ||
| 297 | if domain != "" { | 460 | if domain != "" { | ||
| 298 | return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset) | 461 | return a.deps.History.TimelineByDomain(ctx, domain, historyTimelineLimit, a.historyOffset) | ||
| @@ -305,6 +468,7 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||||
| 305 | a.resetRouteState() | 468 | a.resetRouteState() | ||
| 306 | a.renderedHTML = renderAppFrame(renderedPage{ | 469 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 307 | route: a.currentRoute, | 470 | route: a.currentRoute, | ||
| 471 | + | title: routeDocumentTitle(RouteFavorites), | |||
| 308 | subtitle: "Saved bookmarks", | 472 | subtitle: "Saved bookmarks", | ||
| 309 | body: placeholderHTML(a.currentRoute), | 473 | body: placeholderHTML(a.currentRoute), | ||
| 310 | }, a.shellTheme) | 474 | }, a.shellTheme) | ||
| @@ -329,18 +493,20 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||||
| 329 | a.favorites = favorites | 493 | a.favorites = favorites | ||
| 330 | a.folders = folders | 494 | a.folders = folders | ||
| 331 | a.tags = tags | 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 | a.renderedHTML = renderAppFrame(renderedPage{ | 505 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 333 | route: RouteFavorites, | 506 | route: RouteFavorites, | ||
| 507 | + | title: favoritesDocumentTitle(data), | |||
| 334 | subtitle: "Saved bookmarks", | 508 | subtitle: "Saved bookmarks", | ||
| 335 | - | body: favoritesHTML(favoritesRenderData{ | 509 | + | body: favoritesHTML(data), |
| 336 | - | Favorites: favorites, | |||
| 337 | - | Folders: folders, | |||
| 338 | - | Tags: tags, | |||
| 339 | - | FolderFilter: a.favoriteFolderFilter, | |||
| 340 | - | TagFilter: a.favoriteTagFilter, | |||
| 341 | - | Notice: a.favoritesNotice, | |||
| 342 | - | Error: a.favoritesError, | |||
| 343 | - | }), | |||
| 344 | }, a.shellTheme) | 510 | }, a.shellTheme) | ||
| 345 | return nil | 511 | return nil | ||
| 346 | } | 512 | } | ||
| @@ -350,6 +516,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||||
| 350 | a.resetRouteState() | 516 | a.resetRouteState() | ||
| 351 | a.renderedHTML = renderAppFrame(renderedPage{ | 517 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 352 | route: a.currentRoute, | 518 | route: a.currentRoute, | ||
| 519 | + | title: routeDocumentTitle(RouteConfig), | |||
| 353 | subtitle: "Browser settings", | 520 | subtitle: "Browser settings", | ||
| 354 | body: placeholderHTML(a.currentRoute), | 521 | body: placeholderHTML(a.currentRoute), | ||
| 355 | }, a.shellTheme) | 522 | }, a.shellTheme) | ||
| @@ -373,6 +540,7 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||||
| 373 | a.keybindings = keybindings | 540 | a.keybindings = keybindings | ||
| 374 | a.renderedHTML = renderAppFrame(renderedPage{ | 541 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 375 | route: RouteConfig, | 542 | route: RouteConfig, | ||
| 543 | + | title: "Config — Dumber", | |||
| 376 | subtitle: "Browser settings", | 544 | subtitle: "Browser settings", | ||
| 377 | body: configHTML(configRenderData{ | 545 | body: configHTML(configRenderData{ | ||
| 378 | Config: config, | 546 | Config: config, | ||
M internal/ui/systemviews/app_test.go +224 -5
| @@ -19,6 +19,11 @@ func TestParseRoute(t *testing.T) { | |||||
| 19 | uri string | 19 | uri string | ||
| 20 | want Route | 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 | {name: "history host", uri: "dumb://history", want: RouteHistory}, | 27 | {name: "history host", uri: "dumb://history", want: RouteHistory}, | ||
| 23 | {name: "history opaque", uri: "dumb:history", want: RouteHistory}, | 28 | {name: "history opaque", uri: "dumb:history", want: RouteHistory}, | ||
| 24 | {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites}, | 29 | {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites}, | ||
| @@ -81,6 +86,7 @@ func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||||
| 81 | assert.NotContains(t, app.renderedHTML, "<body") | 86 | assert.NotContains(t, app.renderedHTML, "<body") | ||
| 82 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | 87 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | ||
| 83 | assert.Contains(t, app.renderedHTML, `data-route="history"`) | 88 | assert.Contains(t, app.renderedHTML, `data-route="history"`) | ||
| 89 | + | assert.Contains(t, app.renderedHTML, `data-page-title="History — 1 entry"`) | |||
| 84 | assert.Contains(t, app.renderedHTML, `sv-section`) | 90 | assert.Contains(t, app.renderedHTML, `sv-section`) | ||
| 85 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | 91 | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | ||
| 86 | } | 92 | } | ||
| @@ -133,11 +139,13 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||||
| 133 | assert.Contains(t, dom.html, "Search result") | 139 | assert.Contains(t, dom.html, "Search result") | ||
| 134 | assert.Contains(t, dom.html, "Query: example") | 140 | assert.Contains(t, dom.html, "Query: example") | ||
| 135 | 141 | ||||
| 142 | + | app.historyOffset = historyTimelineLimit | |||
| 136 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 143 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 137 | Action: historyActionDeleteEntry, | 144 | Action: historyActionDeleteEntry, | ||
| 138 | Data: map[string]string{"id": "42"}, | 145 | Data: map[string]string{"id": "42"}, | ||
| 139 | })) | 146 | })) | ||
| 140 | assert.Equal(t, int64(42), history.deletedEntryID) | 147 | assert.Equal(t, int64(42), history.deletedEntryID) | ||
| 148 | + | assert.Equal(t, 0, app.historyOffset) | |||
| 141 | assert.Contains(t, dom.html, "Deleted history entry") | 149 | assert.Contains(t, dom.html, "Deleted history entry") | ||
| 142 | 150 | ||||
| 143 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 151 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| @@ -154,6 +162,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||||
| 154 | })) | 162 | })) | ||
| 155 | assert.True(t, history.domainCalled) | 163 | assert.True(t, history.domainCalled) | ||
| 156 | assert.Equal(t, "example.com", history.domain) | 164 | assert.Equal(t, "example.com", history.domain) | ||
| 165 | + | assert.Empty(t, app.historyQuery) | |||
| 157 | 166 | ||||
| 158 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 167 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 159 | Action: historyActionDeleteDomain, | 168 | Action: historyActionDeleteDomain, | ||
| @@ -181,6 +190,37 @@ func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) { | |||||
| 181 | }) | 190 | }) | ||
| 182 | } | 191 | } | ||
| 183 | 192 | ||||
| 193 | + | func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) { | |||
| 194 | + | bindErr := errors.New("bind failed") | |||
| 195 | + | dom := &failingActionDOM{bindErr: bindErr} | |||
| 196 | + | app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"}) | |||
| 197 | + | ||||
| 198 | + | err := app.RunWithContext(context.Background()) | |||
| 199 | + | require.ErrorIs(t, err, bindErr) | |||
| 200 | + | assert.Nil(t, app.actionQueue) | |||
| 201 | + | assert.Nil(t, app.actionErrorQueue) | |||
| 202 | + | assert.Nil(t, app.actionCtx) | |||
| 203 | + | assert.False(t, app.actionWorkerActive()) | |||
| 204 | + | require.NotPanics(t, app.Close) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) { | |||
| 208 | + | dom := &fakeActionDOM{} | |||
| 209 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}} | |||
| 210 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 211 | + | ||||
| 212 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 213 | + | cancel() | |||
| 214 | + | err := app.RunWithContext(ctx) | |||
| 215 | + | ||||
| 216 | + | require.ErrorContains(t, err, "systemview action worker unavailable") | |||
| 217 | + | assert.True(t, dom.released) | |||
| 218 | + | assert.Nil(t, app.actionQueue) | |||
| 219 | + | assert.Nil(t, app.actionErrorQueue) | |||
| 220 | + | assert.Nil(t, app.actionCtx) | |||
| 221 | + | assert.False(t, app.actionWorkerActive()) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 184 | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | 224 | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | ||
| 185 | t.Parallel() | 225 | t.Parallel() | ||
| 186 | 226 | ||||
| @@ -278,6 +318,7 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 278 | assert.NotContains(t, app.renderedHTML, "<body") | 318 | assert.NotContains(t, app.renderedHTML, "<body") | ||
| 279 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | 319 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | ||
| 280 | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | 320 | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | ||
| 321 | + | assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`) | |||
| 281 | assert.Contains(t, app.renderedHTML, `sv-section`) | 322 | assert.Contains(t, app.renderedHTML, `sv-section`) | ||
| 282 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | 323 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | ||
| 283 | } | 324 | } | ||
| @@ -306,6 +347,8 @@ func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | |||||
| 306 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | 347 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | ||
| 307 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | 348 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | ||
| 308 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | 349 | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | ||
| 350 | + | assert.Contains(t, app.renderedHTML, `name="tags"`) | |||
| 351 | + | assert.Contains(t, app.renderedHTML, `value="7"`) | |||
| 309 | assert.Contains(t, app.renderedHTML, "Read Later") | 352 | assert.Contains(t, app.renderedHTML, "Read Later") | ||
| 310 | assert.Contains(t, app.renderedHTML, "Go") | 353 | assert.Contains(t, app.renderedHTML, "Go") | ||
| 311 | assert.Contains(t, app.renderedHTML, "Shortcut 3") | 354 | assert.Contains(t, app.renderedHTML, "Shortcut 3") | ||
| @@ -322,11 +365,12 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||||
| 322 | 365 | ||||
| 323 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 366 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 324 | Action: favoriteActionCreate, | 367 | Action: favoriteActionCreate, | ||
| 325 | - | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5"}, | 368 | + | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"}, |
| 326 | })) | 369 | })) | ||
| 327 | require.NotNil(t, favorites.createdFavorite.FolderID) | 370 | require.NotNil(t, favorites.createdFavorite.FolderID) | ||
| 328 | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | 371 | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | ||
| 329 | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | 372 | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | ||
| 373 | + | assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags) | |||
| 330 | assert.Contains(t, dom.html, "Added favorite New") | 374 | assert.Contains(t, dom.html, "Added favorite New") | ||
| 331 | 375 | ||||
| 332 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 376 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| @@ -362,6 +406,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||||
| 362 | assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter) | 406 | assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter) | ||
| 363 | assert.Nil(t, app.favoriteFolderFilter) | 407 | assert.Nil(t, app.favoriteFolderFilter) | ||
| 364 | 408 | ||||
| 409 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 410 | + | Action: folderActionCreate, | |||
| 411 | + | Data: map[string]string{"name": "Reading", "icon": "📚"}, | |||
| 412 | + | })) | |||
| 413 | + | assert.Equal(t, "Reading", favorites.createdFolder) | |||
| 414 | + | assert.Equal(t, "📚", favorites.createdFolderIcon) | |||
| 415 | + | assert.Contains(t, dom.html, "Created folder 📚 Reading") | |||
| 416 | + | ||||
| 365 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 417 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 366 | Action: favoriteActionDelete, | 418 | Action: favoriteActionDelete, | ||
| 367 | Data: map[string]string{"id": "42"}, | 419 | Data: map[string]string{"id": "42"}, | ||
| @@ -492,6 +544,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||||
| 492 | assert.NotContains(t, app.renderedHTML, "<body") | 544 | assert.NotContains(t, app.renderedHTML, "<body") | ||
| 493 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | 545 | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | ||
| 494 | assert.Contains(t, app.renderedHTML, `data-route="config"`) | 546 | assert.Contains(t, app.renderedHTML, `data-route="config"`) | ||
| 547 | + | assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`) | |||
| 495 | assert.Contains(t, app.renderedHTML, `sv-section`) | 548 | assert.Contains(t, app.renderedHTML, `sv-section`) | ||
| 496 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | 549 | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | ||
| 497 | } | 550 | } | ||
| @@ -580,12 +633,20 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||||
| 580 | assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB) | 633 | assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB) | ||
| 581 | assert.Contains(t, dom.html, "Saved performance settings") | 634 | assert.Contains(t, dom.html, "Saved performance settings") | ||
| 582 | 635 | ||||
| 636 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 637 | + | Action: configActionSavePerformance, | |||
| 638 | + | Data: map[string]string{"profile": "default"}, | |||
| 639 | + | })) | |||
| 640 | + | assert.Equal(t, "default", service.savedConfig.Performance.Profile) | |||
| 641 | + | assert.Contains(t, dom.html, "Saved performance settings") | |||
| 642 | + | ||||
| 583 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 643 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| 584 | Action: configActionSetKeybinding, | 644 | Action: configActionSetKeybinding, | ||
| 585 | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"}, | 645 | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"}, | ||
| 586 | })) | 646 | })) | ||
| 587 | assert.True(t, service.calledSet) | 647 | assert.True(t, service.calledSet) | ||
| 588 | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | 648 | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | ||
| 649 | + | assert.Contains(t, service.setReq.RequestID, "systemviews-config-") | |||
| 589 | assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview") | 650 | assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview") | ||
| 590 | 651 | ||||
| 591 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | 652 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | ||
| @@ -594,6 +655,8 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||||
| 594 | })) | 655 | })) | ||
| 595 | assert.True(t, service.calledReset) | 656 | assert.True(t, service.calledReset) | ||
| 596 | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | 657 | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | ||
| 658 | + | assert.Contains(t, service.resetReq.RequestID, "systemviews-config-") | |||
| 659 | + | assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID) | |||
| 597 | assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview") | 660 | assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview") | ||
| 598 | 661 | ||||
| 599 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | 662 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | ||
| @@ -601,6 +664,27 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||||
| 601 | assert.Contains(t, dom.html, "Reset all keybindings to defaults") | 664 | assert.Contains(t, dom.html, "Reset all keybindings to defaults") | ||
| 602 | } | 665 | } | ||
| 603 | 666 | ||||
| 667 | + | func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) { | |||
| 668 | + | t.Parallel() | |||
| 669 | + | ||||
| 670 | + | service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()} | |||
| 671 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |||
| 672 | + | ||||
| 673 | + | err := app.handleConfigAction(context.Background(), DOMAction{ | |||
| 674 | + | Action: configActionSaveSearch, | |||
| 675 | + | Data: map[string]string{"default_search_engine": "https://example.com/search"}, | |||
| 676 | + | }) | |||
| 677 | + | require.Error(t, err) | |||
| 678 | + | assert.False(t, service.calledSave) | |||
| 679 | + | ||||
| 680 | + | err = app.handleConfigAction(context.Background(), DOMAction{ | |||
| 681 | + | Action: configActionCreateSearchShortcut, | |||
| 682 | + | Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"}, | |||
| 683 | + | }) | |||
| 684 | + | require.Error(t, err) | |||
| 685 | + | assert.False(t, service.calledSave) | |||
| 686 | + | } | |||
| 687 | + | ||||
| 604 | func testConfigPayload() port.SystemviewConfigPayload { | 688 | func testConfigPayload() port.SystemviewConfigPayload { | ||
| 605 | return port.SystemviewConfigPayload{ | 689 | return port.SystemviewConfigPayload{ | ||
| 606 | EngineType: "webkit", | 690 | EngineType: "webkit", | ||
| @@ -639,9 +723,9 @@ type fakeDOM struct { | |||||
| 639 | html string | 723 | html string | ||
| 640 | } | 724 | } | ||
| 641 | 725 | ||||
| 642 | - | func (d *fakeDOM) Mount(html string) error { | 726 | + | func (d *fakeDOM) Mount(markup string) error { |
| 643 | d.mounted = true | 727 | d.mounted = true | ||
| 644 | - | d.html = html | 728 | + | d.html = markup |
| 645 | return nil | 729 | return nil | ||
| 646 | } | 730 | } | ||
| 647 | 731 | ||||
| @@ -660,6 +744,15 @@ func (d *fakeActionDOM) Release() { | |||||
| 660 | d.released = true | 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 | // Handwritten fake to capture history state for stateful render assertions. | 756 | // Handwritten fake to capture history state for stateful render assertions. | ||
| 664 | type fakeHistoryService struct { | 757 | type fakeHistoryService struct { | ||
| 665 | called bool | 758 | called bool | ||
| @@ -742,6 +835,7 @@ type fakeFavoritesService struct { | |||||
| 742 | updatedFavorite port.FavoriteUpdateInput | 835 | updatedFavorite port.FavoriteUpdateInput | ||
| 743 | deletedFavorite int64 | 836 | deletedFavorite int64 | ||
| 744 | createdFolder string | 837 | createdFolder string | ||
| 838 | + | createdFolderIcon string | |||
| 745 | updatedFolderID int64 | 839 | updatedFolderID int64 | ||
| 746 | deletedFolderID int64 | 840 | deletedFolderID int64 | ||
| 747 | createdTag string | 841 | createdTag string | ||
| @@ -787,9 +881,10 @@ func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { | |||||
| 787 | 881 | ||||
| 788 | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | 882 | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | ||
| 789 | 883 | ||||
| 790 | - | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name string, _ *int64) (*entity.Folder, error) { | 884 | + | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) { |
| 791 | s.createdFolder = name | 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 | func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { | 890 | func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { | ||
| @@ -897,3 +992,127 @@ func (s *fakeConfigService) ResetAllKeybindings(context.Context) error { | |||||
| 897 | s.calledResetAll = true | 992 | s.calledResetAll = true | ||
| 898 | return nil | 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 | package systemviews | 1 | package systemviews | ||
| 2 | 2 | ||||
| 3 | templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) { | 3 | templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) { | ||
| 4 | - | <div class={ appRootClass(theme) } data-route={ string(page.route) } | 4 | + | <div class={ appRootClass(theme) } data-route={ string(page.route) } data-page-title={ pageDocumentTitle(page) } |
| 5 | if theme.InlineVars != "" { | 5 | if theme.InlineVars != "" { | ||
| 6 | style={ theme.InlineVars } | 6 | style={ theme.InlineVars } | ||
| 7 | } | 7 | } | ||
| @@ -38,7 +38,7 @@ templ Alert(kind string, message string) { | |||||
| 38 | } | 38 | } | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | - | templ List(emptyMessage string) { | 41 | + | templ List() { |
| 42 | <ul class="sv-list"> | 42 | <ul class="sv-list"> | ||
| 43 | { children... } | 43 | { children... } | ||
| 44 | </ul> | 44 | </ul> | ||
M internal/ui/systemviews/components_templ.go +133 -120 generated file: collapsed
M internal/ui/systemviews/config.templ +17 -15
| @@ -47,13 +47,13 @@ templ AppearanceForm(config port.SystemviewConfigPayload) { | |||||
| 47 | templ PaletteFields(title string, prefix string, palette port.ColorPalette) { | 47 | templ PaletteFields(title string, prefix string, palette port.ColorPalette) { | ||
| 48 | <fieldset class="sv-fieldset"> | 48 | <fieldset class="sv-fieldset"> | ||
| 49 | <legend>{ title }</legend> | 49 | <legend>{ title }</legend> | ||
| 50 | - | <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label> | 50 | + | <label><span>Background</span><input name={ prefix + "_background" } type="color" value={ palette.Background } required/></label> |
| 51 | - | <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label> | 51 | + | <label><span>Surface</span><input name={ prefix + "_surface" } type="color" value={ palette.Surface } required/></label> |
| 52 | - | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label> | 52 | + | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="color" value={ palette.SurfaceVariant } required/></label> |
| 53 | - | <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label> | 53 | + | <label><span>Text</span><input name={ prefix + "_text" } type="color" value={ palette.Text } required/></label> |
| 54 | - | <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label> | 54 | + | <label><span>Muted</span><input name={ prefix + "_muted" } type="color" value={ palette.Muted } required/></label> |
| 55 | - | <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label> | 55 | + | <label><span>Accent</span><input name={ prefix + "_accent" } type="color" value={ palette.Accent } required/></label> |
| 56 | - | <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label> | 56 | + | <label><span>Border</span><input name={ prefix + "_border" } type="color" value={ palette.Border } required/></label> |
| 57 | </fieldset> | 57 | </fieldset> | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| @@ -67,7 +67,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) { | |||||
| 67 | <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p> | 67 | <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p> | ||
| 68 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | 68 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | ||
| 69 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | 69 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | ||
| 70 | - | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label> | 70 | + | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label> |
| 71 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | 71 | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | ||
| 72 | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | 72 | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | ||
| 73 | </form> | 73 | </form> | ||
| @@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||||
| 89 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.update"> | 89 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.update"> | ||
| 90 | <input type="hidden" name="key" value={ shortcut.Key }/> | 90 | <input type="hidden" name="key" value={ shortcut.Key }/> | ||
| 91 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | 91 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | ||
| 92 | - | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label> | 92 | + | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label> |
| 93 | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | 93 | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | ||
| 94 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 94 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | ||
| 95 | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | 95 | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | ||
| @@ -98,6 +98,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | templ PerformanceSettings(config port.SystemviewConfigPayload) { | 100 | templ PerformanceSettings(config port.SystemviewConfigPayload) { | ||
| 101 | + | {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }} | |||
| 101 | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save"> | 102 | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save"> | ||
| 102 | <label><span>Profile</span><select name="profile"> | 103 | <label><span>Profile</span><select name="profile"> | ||
| 103 | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | 104 | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | ||
| @@ -105,11 +106,12 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) { | |||||
| 105 | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | 106 | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | ||
| 106 | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | 107 | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | ||
| 107 | </select></label> | 108 | </select></label> | ||
| 108 | - | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label> | 109 | + | <p class="sv-meta">Choose Custom to edit individual performance values.</p> |
| 109 | - | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label> | 110 | + | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile }/></label> |
| 110 | - | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label> | 111 | + | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile }/></label> |
| 111 | - | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label> | 112 | + | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile }/></label> |
| 112 | - | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label> | 113 | + | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile }/></label> |
| 114 | + | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile }/></label> | |||
| 113 | <button class="sv-button" type="submit">Save performance</button> | 115 | <button class="sv-button" type="submit">Save performance</button> | ||
| 114 | </form> | 116 | </form> | ||
| 115 | <div class="sv-stat-grid"> | 117 | <div class="sv-stat-grid"> | ||
| @@ -183,7 +185,7 @@ templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding | |||||
| 183 | <form class="sv-inline-form" data-sv-action="config.keybinding.set"> | 185 | <form class="sv-inline-form" data-sv-action="config.keybinding.set"> | ||
| 184 | <input type="hidden" name="mode" value={ group.Mode }/> | 186 | <input type="hidden" name="mode" value={ group.Mode }/> | ||
| 185 | <input type="hidden" name="action" value={ binding.Action }/> | 187 | <input type="hidden" name="action" value={ binding.Action }/> | ||
| 186 | - | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label> | 188 | + | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label> |
| 187 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | 189 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | ||
| 188 | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> | 190 | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> | ||
| 189 | </form> | 191 | </form> | ||
M internal/ui/systemviews/config_actions.go +154 -31
| @@ -3,26 +3,39 @@ package systemviews | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 7 | + | "net/url" | |||
| 6 | "strconv" | 8 | "strconv" | ||
| 7 | "strings" | 9 | "strings" | ||
| 10 | + | "sync/atomic" | |||
| 8 | 11 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 12 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| 12 | const ( | 15 | const ( | ||
| 13 | - | configActionSaveAppearance = "config.appearance.save" | 16 | + | configActionSaveAppearance = "config.appearance.save" |
| 14 | - | configActionResetAppearance = "config.appearance.reset" | 17 | + | configActionResetAppearance = "config.appearance.reset" |
| 15 | - | configActionSaveSearch = "config.search.save" | 18 | + | configActionSaveSearch = "config.search.save" |
| 16 | - | configActionCreateSearchShortcut = "config.searchShortcut.create" | 19 | + | configActionCreateSearchShortcut = "config.searchShortcut.create" |
| 17 | - | configActionUpdateSearchShortcut = "config.searchShortcut.update" | 20 | + | configActionUpdateSearchShortcut = "config.searchShortcut.update" |
| 18 | - | configActionDeleteSearchShortcut = "config.searchShortcut.delete" | 21 | + | configActionDeleteSearchShortcut = "config.searchShortcut.delete" |
| 19 | - | configActionSavePerformance = "config.performance.save" | 22 | + | configActionSavePerformance = "config.performance.save" |
| 20 | - | configActionSetKeybinding = "config.keybinding.set" | 23 | + | configActionSetKeybinding = "config.keybinding.set" |
| 21 | - | configActionResetKeybinding = "config.keybinding.reset" | 24 | + | configActionResetKeybinding = "config.keybinding.reset" |
| 22 | - | configActionResetAllKeybindings = "config.keybinding.resetAll" | 25 | + | configActionResetAllKeybindings = "config.keybinding.resetAll" |
| 23 | - | configRequestIDPrefix = "systemviews-config" | 26 | + | configRequestIDPrefix = "systemviews-config" |
| 27 | + | maxCustomPerformanceThreads = 8 | |||
| 28 | + | maxCustomPerformanceWebMemoryMB = 16384 | |||
| 29 | + | maxCustomPerformanceNetworkMemoryMB = 4096 | |||
| 30 | + | maxCustomPerformancePrewarm = 20 | |||
| 24 | ) | 31 | ) | ||
| 25 | 32 | ||||
| 33 | + | var configRequestCounter atomic.Uint64 | |||
| 34 | + | ||||
| 35 | + | func nextConfigRequestID() string { | |||
| 36 | + | return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1)) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 26 | func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | 39 | func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | ||
| 27 | if a.deps.Config == nil { | 40 | if a.deps.Config == nil { | ||
| 28 | return fmt.Errorf("config service not configured") | 41 | return fmt.Errorf("config service not configured") | ||
| @@ -52,8 +65,10 @@ func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | |||||
| 52 | return err | 65 | return err | ||
| 53 | } | 66 | } | ||
| 54 | a.configNotice = "Reset all keybindings to defaults" | 67 | a.configNotice = "Reset all keybindings to defaults" | ||
| 68 | + | return nil | |||
| 69 | + | default: | |||
| 70 | + | return fmt.Errorf("unknown config action: %q", event.Action) | |||
| 55 | } | 71 | } | ||
| 56 | - | return nil | |||
| 57 | } | 72 | } | ||
| 58 | 73 | ||||
| 59 | func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error { | 74 | func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error { | ||
| @@ -70,14 +85,28 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) | |||||
| 70 | if err != nil { | 85 | if err != nil { | ||
| 71 | return err | 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 | cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"]) | 95 | cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"]) | ||
| 75 | cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"]) | 96 | cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"]) | ||
| 76 | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | 97 | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | ||
| 77 | cfg.Appearance.DefaultFontSize = fontSize | 98 | cfg.Appearance.DefaultFontSize = fontSize | ||
| 78 | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | 99 | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | ||
| 79 | - | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | 100 | + | lightPalette, err := paletteFromForm(data, "light") |
| 80 | - | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | 101 | + | if err != nil { |
| 102 | + | return err | |||
| 103 | + | } | |||
| 104 | + | darkPalette, err := paletteFromForm(data, "dark") | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | cfg.Appearance.LightPalette = lightPalette | |||
| 109 | + | cfg.Appearance.DarkPalette = darkPalette | |||
| 81 | cfg.DefaultUIScale = uiScale | 110 | cfg.DefaultUIScale = uiScale | ||
| 82 | 111 | ||||
| 83 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | 112 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | ||
| @@ -110,7 +139,11 @@ func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) erro | |||||
| 110 | if err != nil { | 139 | if err != nil { | ||
| 111 | return err | 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 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | 147 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | ||
| 115 | return err | 148 | return err | ||
| 116 | } | 149 | } | ||
| @@ -127,6 +160,9 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) | |||||
| 127 | if key == "" { | 160 | if key == "" { | ||
| 128 | return fmt.Errorf("search shortcut key is required") | 161 | return fmt.Errorf("search shortcut key is required") | ||
| 129 | } | 162 | } | ||
| 163 | + | if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil { | |||
| 164 | + | return err | |||
| 165 | + | } | |||
| 130 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | 166 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | ||
| 131 | if _, exists := cfg.SearchShortcuts[key]; exists { | 167 | if _, exists := cfg.SearchShortcuts[key]; exists { | ||
| 132 | return fmt.Errorf("search shortcut %q already exists", key) | 168 | return fmt.Errorf("search shortcut %q already exists", key) | ||
| @@ -152,6 +188,9 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) | |||||
| 152 | if newKey == "" { | 188 | if newKey == "" { | ||
| 153 | return fmt.Errorf("new search shortcut key is required") | 189 | return fmt.Errorf("new search shortcut key is required") | ||
| 154 | } | 190 | } | ||
| 191 | + | if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil { | |||
| 192 | + | return err | |||
| 193 | + | } | |||
| 155 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | 194 | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | ||
| 156 | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | 195 | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | ||
| 157 | return fmt.Errorf("search shortcut %q not found", oldKey) | 196 | return fmt.Errorf("search shortcut %q not found", oldKey) | ||
| @@ -197,6 +236,15 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) | |||||
| 197 | return err | 236 | return err | ||
| 198 | } | 237 | } | ||
| 199 | profile := strings.TrimSpace(data["profile"]) | 238 | profile := strings.TrimSpace(data["profile"]) | ||
| 239 | + | cfg.Performance.Profile = profile | |||
| 240 | + | if profile != "custom" { | |||
| 241 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 242 | + | return err | |||
| 243 | + | } | |||
| 244 | + | a.configNotice = "Saved performance settings. Restart may be required." | |||
| 245 | + | return nil | |||
| 246 | + | } | |||
| 247 | + | ||||
| 200 | skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads") | 248 | skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads") | ||
| 201 | if err != nil { | 249 | if err != nil { | ||
| 202 | return err | 250 | return err | ||
| @@ -217,8 +265,22 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) | |||||
| 217 | if err != nil { | 265 | if err != nil { | ||
| 218 | return err | 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 | cfg.Performance.Custom = port.WebUICustomPerformanceConfig{ | 284 | cfg.Performance.Custom = port.WebUICustomPerformanceConfig{ | ||
| 223 | SkiaCPUThreads: skiaCPU, | 285 | SkiaCPUThreads: skiaCPU, | ||
| 224 | SkiaGPUThreads: skiaGPU, | 286 | SkiaGPUThreads: skiaGPU, | ||
| @@ -244,7 +306,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e | |||||
| 244 | return err | 306 | return err | ||
| 245 | } | 307 | } | ||
| 246 | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | 308 | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | ||
| 247 | - | RequestID: configRequestIDPrefix, | 309 | + | RequestID: nextConfigRequestID(), |
| 248 | Mode: mode, | 310 | Mode: mode, | ||
| 249 | Action: action, | 311 | Action: action, | ||
| 250 | Keys: keys, | 312 | Keys: keys, | ||
| @@ -270,7 +332,7 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) | |||||
| 270 | return err | 332 | return err | ||
| 271 | } | 333 | } | ||
| 272 | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | 334 | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | ||
| 273 | - | RequestID: configRequestIDPrefix, | 335 | + | RequestID: nextConfigRequestID(), |
| 274 | Mode: mode, | 336 | Mode: mode, | ||
| 275 | Action: action, | 337 | Action: action, | ||
| 276 | }); err != nil { | 338 | }); err != nil { | ||
| @@ -344,16 +406,57 @@ func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]p | |||||
| 344 | return clone | 406 | return clone | ||
| 345 | } | 407 | } | ||
| 346 | 408 | ||||
| 347 | - | func paletteFromForm(data map[string]string, prefix string) port.ColorPalette { | 409 | + | func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette, error) { |
| 348 | - | return port.ColorPalette{ | 410 | + | color := func(field string) (string, error) { |
| 349 | - | Background: strings.TrimSpace(data[prefix+"_background"]), | 411 | + | value := strings.TrimSpace(data[prefix+"_"+field]) |
| 350 | - | Surface: strings.TrimSpace(data[prefix+"_surface"]), | 412 | + | if value == "" || isValidHexColor(value) { |
| 351 | - | SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]), | 413 | + | return value, nil |
| 352 | - | Text: strings.TrimSpace(data[prefix+"_text"]), | 414 | + | } |
| 353 | - | Muted: strings.TrimSpace(data[prefix+"_muted"]), | 415 | + | return "", fmt.Errorf("%s_%s must be a hex color", prefix, field) |
| 354 | - | Accent: strings.TrimSpace(data[prefix+"_accent"]), | 416 | + | } |
| 355 | - | Border: strings.TrimSpace(data[prefix+"_border"]), | 417 | + | |
| 418 | + | background, err := color("background") | |||
| 419 | + | if err != nil { | |||
| 420 | + | return port.ColorPalette{}, err | |||
| 421 | + | } | |||
| 422 | + | surface, err := color("surface") | |||
| 423 | + | if err != nil { | |||
| 424 | + | return port.ColorPalette{}, err | |||
| 425 | + | } | |||
| 426 | + | surfaceVariant, err := color("surface_variant") | |||
| 427 | + | if err != nil { | |||
| 428 | + | return port.ColorPalette{}, err | |||
| 429 | + | } | |||
| 430 | + | text, err := color("text") | |||
| 431 | + | if err != nil { | |||
| 432 | + | return port.ColorPalette{}, err | |||
| 433 | + | } | |||
| 434 | + | muted, err := color("muted") | |||
| 435 | + | if err != nil { | |||
| 436 | + | return port.ColorPalette{}, err | |||
| 437 | + | } | |||
| 438 | + | accent, err := color("accent") | |||
| 439 | + | if err != nil { | |||
| 440 | + | return port.ColorPalette{}, err | |||
| 356 | } | 441 | } | ||
| 442 | + | border, err := color("border") | |||
| 443 | + | if err != nil { | |||
| 444 | + | return port.ColorPalette{}, err | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | return port.ColorPalette{ | |||
| 448 | + | Background: background, | |||
| 449 | + | Surface: surface, | |||
| 450 | + | SurfaceVariant: surfaceVariant, | |||
| 451 | + | Text: text, | |||
| 452 | + | Muted: muted, | |||
| 453 | + | Accent: accent, | |||
| 454 | + | Border: border, | |||
| 455 | + | }, nil | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | func isValidHexColor(value string) bool { | |||
| 459 | + | return isHexColor(strings.TrimSpace(value)) | |||
| 357 | } | 460 | } | ||
| 358 | 461 | ||||
| 359 | func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) { | 462 | func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) { | ||
| @@ -363,6 +466,21 @@ func searchShortcutFromForm(data map[string]string, keyField string) (string, po | |||||
| 363 | } | 466 | } | ||
| 364 | } | 467 | } | ||
| 365 | 468 | ||||
| 469 | + | func requireSearchURLTemplate(raw, label string) (string, error) { | |||
| 470 | + | value := strings.TrimSpace(raw) | |||
| 471 | + | if value == "" { | |||
| 472 | + | return "", fmt.Errorf("%s is required", label) | |||
| 473 | + | } | |||
| 474 | + | if !strings.Contains(value, "%s") { | |||
| 475 | + | return "", fmt.Errorf("%s must include %%s placeholder", label) | |||
| 476 | + | } | |||
| 477 | + | parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1)) | |||
| 478 | + | if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { | |||
| 479 | + | return "", fmt.Errorf("%s must be an absolute http(s) URL", label) | |||
| 480 | + | } | |||
| 481 | + | return value, nil | |||
| 482 | + | } | |||
| 483 | + | ||||
| 366 | func parseConfigInt(raw, label string) (int, error) { | 484 | func parseConfigInt(raw, label string) (int, error) { | ||
| 367 | value, err := strconv.Atoi(strings.TrimSpace(raw)) | 485 | value, err := strconv.Atoi(strings.TrimSpace(raw)) | ||
| 368 | if err != nil { | 486 | if err != nil { | ||
| @@ -379,15 +497,20 @@ func parseConfigFloat(raw, label string) (float64, error) { | |||||
| 379 | return value, nil | 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 | func parseKeyList(raw string) []string { | 507 | func parseKeyList(raw string) []string { | ||
| 383 | raw = strings.TrimSpace(raw) | 508 | raw = strings.TrimSpace(raw) | ||
| 384 | if raw == "" { | 509 | if raw == "" { | ||
| 385 | return nil | 510 | return nil | ||
| 386 | } | 511 | } | ||
| 387 | - | parts := strings.Split(raw, ",") | 512 | + | raw = strings.ReplaceAll(raw, ",", " ") |
| 388 | - | if len(parts) == 1 { | 513 | + | parts := strings.Fields(raw) |
| 389 | - | parts = strings.Fields(raw) | |||
| 390 | - | } | |||
| 391 | keys := make([]string, 0, len(parts)) | 514 | keys := make([]string, 0, len(parts)) | ||
| 392 | for _, part := range parts { | 515 | for _, part := range parts { | ||
| 393 | key := strings.TrimSpace(part) | 516 | key := strings.TrimSpace(part) | ||
M internal/ui/systemviews/config_templ.go +119 -68 generated file: collapsed
M internal/ui/systemviews/config_view.go +9 -2
| @@ -16,6 +16,7 @@ type configRenderData struct { | |||||
| 16 | Error string | 16 | Error string | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | + | // maxFlattenDepth caps recursive config flattening to avoid runaway nesting. | |||
| 19 | const maxFlattenDepth = 16 | 20 | const maxFlattenDepth = 16 | ||
| 20 | 21 | ||||
| 21 | func configHTML(data configRenderData) string { | 22 | func configHTML(data configRenderData) string { | ||
| @@ -253,6 +254,9 @@ func formatFlatValue(value reflect.Value) string { | |||||
| 253 | case reflect.Complex64, reflect.Complex128: | 254 | case reflect.Complex64, reflect.Complex128: | ||
| 254 | return fmt.Sprint(value.Complex()) | 255 | return fmt.Sprint(value.Complex()) | ||
| 255 | default: | 256 | default: | ||
| 257 | + | if !value.CanInterface() { | |||
| 258 | + | return "<unexported>" | |||
| 259 | + | } | |||
| 256 | return fmt.Sprint(value.Interface()) | 260 | return fmt.Sprint(value.Interface()) | ||
| 257 | } | 261 | } | ||
| 258 | } | 262 | } | ||
| @@ -275,7 +279,10 @@ func mapKeyString(value reflect.Value) string { | |||||
| 275 | if value.Kind() == reflect.String { | 279 | if value.Kind() == reflect.String { | ||
| 276 | return value.String() | 280 | return value.String() | ||
| 277 | } | 281 | } | ||
| 278 | - | return fmt.Sprint(value.Interface()) | 282 | + | if value.CanInterface() { |
| 283 | + | return fmt.Sprint(value.Interface()) | |||
| 284 | + | } | |||
| 285 | + | return formatFlatValue(value) | |||
| 279 | } | 286 | } | ||
| 280 | 287 | ||||
| 281 | func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) { | 288 | func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) { | ||
| @@ -344,5 +351,5 @@ func plural(count int, singular string) string { | |||||
| 344 | if count == 1 { | 351 | if count == 1 { | ||
| 345 | return singular | 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 | type DOM interface { | 8 | type DOM interface { | ||
| 9 | - | Mount(html string) error | 9 | + | Mount(markup string) error |
| 10 | } | 10 | } | ||
| 11 | 11 | ||||
| 12 | // DOMAction is a browser event delegated from the mounted systemview DOM. | 12 | // DOMAction is a browser event delegated from the mounted systemview DOM. | ||
M internal/ui/systemviews/dom_js.go +115 -11
| @@ -28,21 +28,79 @@ func NewDOM() DOM { | |||||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | target := doc.Call("getElementById", "app") | 30 | target := doc.Call("getElementById", "app") | ||
| 31 | - | if !target.Truthy() { | |||
| 32 | - | target = doc.Get("body") | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | return &browserDOM{document: doc, target: target} | 31 | return &browserDOM{document: doc, target: target} | ||
| 36 | } | 32 | } | ||
| 37 | 33 | ||||
| 38 | - | func (d *browserDOM) Mount(html string) error { | 34 | + | func (d *browserDOM) Mount(markup string) error { |
| 39 | if d == nil || !d.target.Truthy() { | 35 | if d == nil || !d.target.Truthy() { | ||
| 40 | - | return fmt.Errorf("DOM target not available") | 36 | + | return fmt.Errorf("DOM mount target #app not found") |
| 41 | } | 37 | } | ||
| 42 | - | d.target.Set("innerHTML", html) | 38 | + | d.target.Set("innerHTML", markup) |
| 39 | + | d.updateDocumentTitle() | |||
| 40 | + | d.scheduleAlertDismissal() | |||
| 43 | return nil | 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 | func (d *browserDOM) BindActions(handler DOMActionHandler) error { | 104 | func (d *browserDOM) BindActions(handler DOMActionHandler) error { | ||
| 47 | if d == nil || !d.target.Truthy() || handler == nil { | 105 | if d == nil || !d.target.Truthy() || handler == nil { | ||
| 48 | return fmt.Errorf("DOM action binding target not available") | 106 | return fmt.Errorf("DOM action binding target not available") | ||
| @@ -58,6 +116,15 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 58 | if !element.Truthy() { | 116 | if !element.Truthy() { | ||
| 59 | return nil | 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 | action := element.Call("getAttribute", "data-sv-action").String() | 128 | action := element.Call("getAttribute", "data-sv-action").String() | ||
| 62 | if action == "" { | 129 | if action == "" { | ||
| 63 | return nil | 130 | return nil | ||
| @@ -84,6 +151,9 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 84 | if action == "" { | 151 | if action == "" { | ||
| 85 | return nil | 152 | return nil | ||
| 86 | } | 153 | } | ||
| 154 | + | if !confirmAction(form) { | |||
| 155 | + | return nil | |||
| 156 | + | } | |||
| 87 | event.Call("preventDefault") | 157 | event.Call("preventDefault") | ||
| 88 | data := collectActionData(form) | 158 | data := collectActionData(form) | ||
| 89 | for key, value := range collectFormData(form) { | 159 | for key, value := range collectFormData(form) { | ||
| @@ -95,7 +165,11 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 95 | d.addEventBinding(d.target, "submit", submitHandler) | 165 | d.addEventBinding(d.target, "submit", submitHandler) | ||
| 96 | 166 | ||||
| 97 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | 167 | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | ||
| 98 | - | d.handleKeyboard(firstJSArg(args)) | 168 | + | event := firstJSArg(args) |
| 169 | + | if !d.eventTargetInsideMount(event) { | |||
| 170 | + | return nil | |||
| 171 | + | } | |||
| 172 | + | d.handleKeyboard(event) | |||
| 99 | return nil | 173 | return nil | ||
| 100 | }) | 174 | }) | ||
| 101 | d.addEventBinding(d.document, "keydown", keydownHandler) | 175 | d.addEventBinding(d.document, "keydown", keydownHandler) | ||
| @@ -103,6 +177,26 @@ func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||||
| 103 | return nil | 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 | func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) { | 200 | func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) { | ||
| 107 | if !target.Truthy() { | 201 | if !target.Truthy() { | ||
| 108 | fn.Release() | 202 | fn.Release() | ||
| @@ -172,7 +266,13 @@ func collectFormData(form js.Value) map[string]string { | |||||
| 172 | if pair.Get("length").Int() < 2 { | 266 | if pair.Get("length").Int() < 2 { | ||
| 173 | continue | 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 | return data | 277 | return data | ||
| 178 | } | 278 | } | ||
| @@ -232,6 +332,8 @@ func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | |||||
| 232 | } | 332 | } | ||
| 233 | } | 333 | } | ||
| 234 | case "d", "delete", "backspace": | 334 | case "d", "delete", "backspace": | ||
| 335 | + | // Delete the focused row through the same delegated click path as pointer users, | |||
| 336 | + | // including the data-sv-confirm guard on the delete button. | |||
| 235 | if row := d.activeHistoryRow(); row.Truthy() { | 337 | if row := d.activeHistoryRow(); row.Truthy() { | ||
| 236 | event.Call("preventDefault") | 338 | event.Call("preventDefault") | ||
| 237 | if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() { | 339 | if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() { | ||
| @@ -349,14 +451,16 @@ func (d *browserDOM) focusRow(selector, datasetKey string, delta int) { | |||||
| 349 | func (d *browserDOM) activeRow(selector, datasetKey string) js.Value { | 451 | func (d *browserDOM) activeRow(selector, datasetKey string) js.Value { | ||
| 350 | rows := d.target.Call("querySelectorAll", selector) | 452 | rows := d.target.Call("querySelectorAll", selector) | ||
| 351 | length := rows.Get("length").Int() | 453 | length := rows.Get("length").Int() | ||
| 352 | - | if length == 0 { | 454 | + | if length <= 0 { |
| 353 | return js.Value{} | 455 | return js.Value{} | ||
| 354 | } | 456 | } | ||
| 355 | idx := d.activeIndex(datasetKey) | 457 | idx := d.activeIndex(datasetKey) | ||
| 356 | if idx < 0 || idx >= length { | 458 | if idx < 0 || idx >= length { | ||
| 357 | idx = 0 | 459 | idx = 0 | ||
| 460 | + | row := rows.Index(idx) | |||
| 358 | d.target.Get("dataset").Set(datasetKey, "0") | 461 | d.target.Get("dataset").Set(datasetKey, "0") | ||
| 359 | - | rows.Index(0).Get("classList").Call("add", "sv-focused") | 462 | + | row.Get("classList").Call("add", "sv-focused") |
| 463 | + | return row | |||
| 360 | } | 464 | } | ||
| 361 | return rows.Index(idx) | 465 | return rows.Index(idx) | ||
| 362 | } | 466 | } | ||
M internal/ui/systemviews/favorite_actions.go +72 -4
| @@ -3,6 +3,7 @@ package systemviews | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net/url" | |||
| 6 | "strconv" | 7 | "strconv" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| @@ -38,10 +39,19 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 38 | if err != nil { | 39 | if err != nil { | ||
| 39 | return err | 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 | favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{ | 50 | favorite, err := a.deps.Favorites.CreateFavorite(ctx, port.FavoriteCreateInput{ | ||
| 42 | - | URL: strings.TrimSpace(data["url"]), | 51 | + | URL: favoriteURL, |
| 43 | Title: strings.TrimSpace(data["title"]), | 52 | Title: strings.TrimSpace(data["title"]), | ||
| 44 | FolderID: folderID, | 53 | FolderID: folderID, | ||
| 54 | + | Tags: tags, | |||
| 45 | }) | 55 | }) | ||
| 46 | if err != nil { | 56 | if err != nil { | ||
| 47 | return err | 57 | return err | ||
| @@ -106,7 +116,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 106 | if name == "" { | 116 | if name == "" { | ||
| 107 | return fmt.Errorf("folder name is required") | 117 | return fmt.Errorf("folder name is required") | ||
| 108 | } | 118 | } | ||
| 109 | - | folder, err := a.deps.Favorites.CreateFolder(ctx, name, nil) | 119 | + | folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil) |
| 110 | if err != nil { | 120 | if err != nil { | ||
| 111 | return err | 121 | return err | ||
| 112 | } | 122 | } | ||
| @@ -168,11 +178,11 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 168 | a.favoriteTagFilter = nil | 178 | a.favoriteTagFilter = nil | ||
| 169 | a.favoritesNotice = "Deleted tag" | 179 | a.favoritesNotice = "Deleted tag" | ||
| 170 | case tagActionAssign, tagActionRemove: | 180 | case tagActionAssign, tagActionRemove: | ||
| 171 | - | favoriteID, err := parsePositiveInt64(data["favoriteId"], "favorite id") | 181 | + | favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id") |
| 172 | if err != nil { | 182 | if err != nil { | ||
| 173 | return err | 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 | if err != nil { | 186 | if err != nil { | ||
| 177 | return err | 187 | return err | ||
| 178 | } | 188 | } | ||
| @@ -187,10 +197,40 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 187 | } | 197 | } | ||
| 188 | a.favoritesNotice = "Removed tag" | 198 | a.favoritesNotice = "Removed tag" | ||
| 189 | } | 199 | } | ||
| 200 | + | default: | |||
| 201 | + | return fmt.Errorf("unknown favorite action: %q", event.Action) | |||
| 190 | } | 202 | } | ||
| 191 | return nil | 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 | func firstActionValue(data map[string]string, keys ...string) string { | 234 | func firstActionValue(data map[string]string, keys ...string) string { | ||
| 195 | for _, key := range keys { | 235 | for _, key := range keys { | ||
| 196 | if value, ok := data[key]; ok { | 236 | if value, ok := data[key]; ok { | ||
| @@ -208,6 +248,31 @@ func parsePositiveInt64(raw, label string) (int64, error) { | |||||
| 208 | return id, nil | 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 | func optionalFolderID(raw string) (*entity.FolderID, error) { | 276 | func optionalFolderID(raw string) (*entity.FolderID, error) { | ||
| 212 | raw = strings.TrimSpace(raw) | 277 | raw = strings.TrimSpace(raw) | ||
| 213 | if raw == "" || raw == "root" { | 278 | if raw == "" || raw == "root" { | ||
| @@ -221,6 +286,8 @@ func optionalFolderID(raw string) (*entity.FolderID, error) { | |||||
| 221 | return &folderID, nil | 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 | func filterFolderID(raw string) (*entity.FolderID, error) { | 291 | func filterFolderID(raw string) (*entity.FolderID, error) { | ||
| 225 | if strings.TrimSpace(raw) == "root" { | 292 | if strings.TrimSpace(raw) == "root" { | ||
| 226 | root := entity.FolderID(0) | 293 | root := entity.FolderID(0) | ||
| @@ -229,6 +296,7 @@ func filterFolderID(raw string) (*entity.FolderID, error) { | |||||
| 229 | return optionalFolderID(raw) | 296 | return optionalFolderID(raw) | ||
| 230 | } | 297 | } | ||
| 231 | 298 | ||||
| 299 | + | // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut. | |||
| 232 | func optionalShortcut(raw string) (*int, error) { | 300 | func optionalShortcut(raw string) (*int, error) { | ||
| 233 | raw = strings.TrimSpace(raw) | 301 | raw = strings.TrimSpace(raw) | ||
| 234 | if raw == "" { | 302 | if raw == "" { | ||
M internal/ui/systemviews/favorites.templ +23 -4
| @@ -62,16 +62,17 @@ templ FavoritesControls(data favoritesRenderData) { | |||||
| 62 | <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label> | 62 | <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label> | ||
| 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | 63 | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | ||
| 64 | @FolderSelect("folder_id", data.Folders, nil, "Folder") | 64 | @FolderSelect("folder_id", data.Folders, nil, "Folder") | ||
| 65 | + | @FavoriteCreateTagPicker(data.Tags) | |||
| 65 | <button class="sv-button" type="submit">Add favorite</button> | 66 | <button class="sv-button" type="submit">Add favorite</button> | ||
| 66 | </form> | 67 | </form> | ||
| 67 | <div class="sv-filter-panel"> | 68 | <div class="sv-filter-panel"> | ||
| 68 | <div class="sv-button-row"> | 69 | <div class="sv-button-row"> | ||
| 69 | <span class="sv-meta">Folders</span> | 70 | <span class="sv-meta">Folders</span> | ||
| 70 | - | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> | 71 | + | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> |
| 71 | - | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button> | 72 | + | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button> |
| 72 | for _, folder := range data.Folders { | 73 | for _, folder := range data.Folders { | ||
| 73 | if folder != nil { | 74 | if folder != nil { | ||
| 74 | - | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button> | 75 | + | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button> |
| 75 | } | 76 | } | ||
| 76 | } | 77 | } | ||
| 77 | </div> | 78 | </div> | ||
| @@ -79,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) { | |||||
| 79 | <span class="sv-meta">Tags</span> | 80 | <span class="sv-meta">Tags</span> | ||
| 80 | for _, tag := range data.Tags { | 81 | for _, tag := range data.Tags { | ||
| 81 | if tag != nil { | 82 | if tag != nil { | ||
| 82 | - | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | 83 | + | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> |
| 83 | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name } | 84 | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name } | ||
| 84 | </button> | 85 | </button> | ||
| 85 | } | 86 | } | ||
| @@ -89,6 +90,24 @@ templ FavoritesControls(data favoritesRenderData) { | |||||
| 89 | <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p> | 90 | <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p> | ||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 93 | + | templ FavoriteCreateTagPicker(tags []*entity.Tag) { | |||
| 94 | + | if len(tags) > 0 { | |||
| 95 | + | <fieldset class="sv-tag-picker"> | |||
| 96 | + | <legend>Tags</legend> | |||
| 97 | + | <div class="sv-tag-controls"> | |||
| 98 | + | for _, tag := range tags { | |||
| 99 | + | if tag != nil { | |||
| 100 | + | <label class="sv-tag-toggle"> | |||
| 101 | + | <input type="checkbox" name="tags" value={ fmt.Sprintf("%d", tag.ID) }/> | |||
| 102 | + | <span><span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }</span> | |||
| 103 | + | </label> | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | </div> | |||
| 107 | + | </fieldset> | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 92 | templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) { | 111 | templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) { | ||
| 93 | <li class="sv-list-row"> | 112 | <li class="sv-list-row"> | ||
| 94 | <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }> | 113 | <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }> | ||
M internal/ui/systemviews/favorites_templ.go +393 -255 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +17 -3
| @@ -21,6 +21,20 @@ func favoritesHTML(data favoritesRenderData) string { | |||||
| 21 | return mustRenderComponent(FavoritesView(data)) | 21 | return mustRenderComponent(FavoritesView(data)) | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | + | func favoritesDocumentTitle(data favoritesRenderData) string { | |||
| 25 | + | count := len(data.Favorites) | |||
| 26 | + | if data.FolderFilter != nil || data.TagFilter != nil { | |||
| 27 | + | if count == 1 { | |||
| 28 | + | return "Favorites — 1 filtered bookmark" | |||
| 29 | + | } | |||
| 30 | + | return fmt.Sprintf("Favorites — %d filtered bookmarks", count) | |||
| 31 | + | } | |||
| 32 | + | if count == 1 { | |||
| 33 | + | return "Favorites — 1 bookmark" | |||
| 34 | + | } | |||
| 35 | + | return fmt.Sprintf("Favorites — %d bookmarks", count) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 24 | func favoritesSummary(data favoritesRenderData) string { | 38 | func favoritesSummary(data favoritesRenderData) string { | ||
| 25 | return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | 39 | return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | ||
| 26 | } | 40 | } | ||
| @@ -68,7 +82,7 @@ func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) strin | |||||
| 68 | if favorite == nil { | 82 | if favorite == nil { | ||
| 69 | return "" | 83 | return "" | ||
| 70 | } | 84 | } | ||
| 71 | - | parts := []string{} | 85 | + | parts := make([]string, 0, 2) |
| 72 | if favorite.ShortcutKey != nil { | 86 | if favorite.ShortcutKey != nil { | ||
| 73 | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | 87 | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | ||
| 74 | } | 88 | } | ||
| @@ -96,9 +110,9 @@ func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string { | |||||
| 96 | } | 110 | } | ||
| 97 | 111 | ||||
| 98 | func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | 112 | func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | ||
| 99 | - | classes := "sv-button sv-button-secondary" | 113 | + | classes := "sv-button sv-button-secondary sv-tag-button" |
| 100 | if favorite != nil && tag != nil && favorite.HasTag(tag.ID) { | 114 | if favorite != nil && tag != nil && favorite.HasTag(tag.ID) { | ||
| 101 | - | classes += " sv-button-active" | 115 | + | classes += " sv-button-active sv-tag-button-active" |
| 102 | } | 116 | } | ||
| 103 | return classes | 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 | if a == nil { | 25 | if a == nil { | ||
| 26 | return fmt.Errorf("app is nil") | 26 | return fmt.Errorf("app is nil") | ||
| 27 | } | 27 | } | ||
| 28 | - | a.mu.Lock() | 28 | + | a.lockState() |
| 29 | - | defer a.mu.Unlock() | 29 | + | defer a.unlockState() |
| 30 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | 30 | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | ||
| 31 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 31 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 32 | } | 32 | } | ||
| @@ -40,6 +40,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 40 | } | 40 | } | ||
| 41 | if err := a.loadHistoryRoute(ctx); err != nil { | 41 | if err := a.loadHistoryRoute(ctx); err != nil { | ||
| 42 | a.renderRouteError(err) | 42 | a.renderRouteError(err) | ||
| 43 | + | _ = a.mountRenderedHTML() | |||
| 44 | + | return err | |||
| 43 | } | 45 | } | ||
| 44 | return a.mountRenderedHTML() | 46 | return a.mountRenderedHTML() | ||
| 45 | case RouteFavorites: | 47 | case RouteFavorites: | ||
| @@ -50,6 +52,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 50 | } | 52 | } | ||
| 51 | if err := a.loadFavoritesRoute(ctx); err != nil { | 53 | if err := a.loadFavoritesRoute(ctx); err != nil { | ||
| 52 | a.renderRouteError(err) | 54 | a.renderRouteError(err) | ||
| 55 | + | _ = a.mountRenderedHTML() | |||
| 56 | + | return err | |||
| 53 | } | 57 | } | ||
| 54 | return a.mountRenderedHTML() | 58 | return a.mountRenderedHTML() | ||
| 55 | case RouteConfig: | 59 | case RouteConfig: | ||
| @@ -61,6 +65,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 61 | a.loadShellTheme(ctx) | 65 | a.loadShellTheme(ctx) | ||
| 62 | if err := a.loadConfigRoute(ctx); err != nil { | 66 | if err := a.loadConfigRoute(ctx); err != nil { | ||
| 63 | a.renderRouteError(err) | 67 | a.renderRouteError(err) | ||
| 68 | + | _ = a.mountRenderedHTML() | |||
| 69 | + | return err | |||
| 64 | } | 70 | } | ||
| 65 | return a.mountRenderedHTML() | 71 | return a.mountRenderedHTML() | ||
| 66 | default: | 72 | default: | ||
| @@ -77,6 +83,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 77 | switch event.Action { | 83 | switch event.Action { | ||
| 78 | case historyActionSearch: | 84 | case historyActionSearch: | ||
| 79 | a.historyQuery = strings.TrimSpace(data["query"]) | 85 | a.historyQuery = strings.TrimSpace(data["query"]) | ||
| 86 | + | a.historyDomainFilter = "" | |||
| 80 | a.historyOffset = 0 | 87 | a.historyOffset = 0 | ||
| 81 | a.historyNotice = "" | 88 | a.historyNotice = "" | ||
| 82 | case historyActionClear: | 89 | case historyActionClear: | ||
| @@ -90,6 +97,7 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 90 | return fmt.Errorf("domain is required") | 97 | return fmt.Errorf("domain is required") | ||
| 91 | } | 98 | } | ||
| 92 | a.historyDomainFilter = domain | 99 | a.historyDomainFilter = domain | ||
| 100 | + | a.historyQuery = "" | |||
| 93 | a.historyOffset = 0 | 101 | a.historyOffset = 0 | ||
| 94 | a.historyNotice = "" | 102 | a.historyNotice = "" | ||
| 95 | case historyActionClearDomain: | 103 | case historyActionClearDomain: | ||
| @@ -111,12 +119,16 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 111 | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | 119 | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | ||
| 112 | return err | 120 | return err | ||
| 113 | } | 121 | } | ||
| 122 | + | a.historyOffset = 0 | |||
| 114 | a.historyNotice = "Deleted history entry" | 123 | a.historyNotice = "Deleted history entry" | ||
| 115 | case historyActionDeleteRange: | 124 | case historyActionDeleteRange: | ||
| 116 | rangeID := strings.TrimSpace(data["range"]) | 125 | rangeID := strings.TrimSpace(data["range"]) | ||
| 117 | if rangeID == "" { | 126 | if rangeID == "" { | ||
| 118 | return fmt.Errorf("history range is required") | 127 | return fmt.Errorf("history range is required") | ||
| 119 | } | 128 | } | ||
| 129 | + | if !isKnownHistoryCleanupRange(rangeID) { | |||
| 130 | + | return fmt.Errorf("invalid history range") | |||
| 131 | + | } | |||
| 120 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | 132 | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | ||
| 121 | return err | 133 | return err | ||
| 122 | } | 134 | } | ||
| @@ -135,15 +147,30 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||||
| 135 | } | 147 | } | ||
| 136 | a.historyOffset = 0 | 148 | a.historyOffset = 0 | ||
| 137 | a.historyNotice = "Deleted history for " + domain | 149 | a.historyNotice = "Deleted history for " + domain | ||
| 150 | + | default: | |||
| 151 | + | return fmt.Errorf("unknown history action: %q", event.Action) | |||
| 138 | } | 152 | } | ||
| 139 | return nil | 153 | return nil | ||
| 140 | } | 154 | } | ||
| 141 | 155 | ||||
| 142 | func (a *App) mountRenderedHTML() error { | 156 | func (a *App) mountRenderedHTML() error { | ||
| 157 | + | return a.mountHTML(a.renderedHTML) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (a *App) mountHTML(html string) error { | |||
| 143 | if a.deps.DOM == nil { | 161 | if a.deps.DOM == nil { | ||
| 144 | return nil | 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 | func historyRangeNotice(rangeID string) string { | 176 | func historyRangeNotice(rangeID string) string { | ||
M internal/ui/systemviews/history_view.go +40 -29
| @@ -38,11 +38,41 @@ func historyHTML(data historyRenderData) string { | |||||
| 38 | return mustRenderComponent(HistoryView(data)) | 38 | return mustRenderComponent(HistoryView(data)) | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | func historyDocumentTitle(data historyRenderData) string { | |||
| 42 | + | if query := strings.TrimSpace(data.Query); query != "" { | |||
| 43 | + | return "History — search: " + truncateTitle(query, 48) | |||
| 44 | + | } | |||
| 45 | + | if domain := strings.TrimSpace(data.DomainFilter); domain != "" { | |||
| 46 | + | label := browserurl.DisplayDomain(domain) | |||
| 47 | + | if label == "" { | |||
| 48 | + | label = domain | |||
| 49 | + | } | |||
| 50 | + | return "History — " + label | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | entries := int64(countHistoryEntries(data.Entries)) | |||
| 54 | + | if data.Analytics != nil { | |||
| 55 | + | entries = data.Analytics.TotalEntries | |||
| 56 | + | } | |||
| 57 | + | if entries == 1 { | |||
| 58 | + | return "History — 1 entry" | |||
| 59 | + | } | |||
| 60 | + | return fmt.Sprintf("History — %d entries", entries) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func truncateTitle(value string, maxRunes int) string { | |||
| 64 | + | runes := []rune(strings.TrimSpace(value)) | |||
| 65 | + | if maxRunes <= 0 || len(runes) <= maxRunes { | |||
| 66 | + | return string(runes) | |||
| 67 | + | } | |||
| 68 | + | return string(runes[:maxRunes]) + "…" | |||
| 69 | + | } | |||
| 70 | + | ||||
| 41 | var historyCleanupRanges = []historyCleanupItem{ | 71 | var historyCleanupRanges = []historyCleanupItem{ | ||
| 42 | - | {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?", Notice: "Deleted history from the last hour"}, | 72 | + | {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"}, |
| 43 | - | {Label: "Today", RangeID: "day", Confirm: "Delete history for today?", Notice: "Deleted history from today"}, | 73 | + | {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"}, |
| 44 | - | {Label: "Week", RangeID: "week", Confirm: "Delete history for week?", Notice: "Deleted history from this week"}, | 74 | + | {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"}, |
| 45 | - | {Label: "Month", RangeID: "month", Confirm: "Delete history for month?", Notice: "Deleted history from this month"}, | 75 | + | {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"}, |
| 46 | {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"}, | 76 | {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"}, | ||
| 47 | } | 77 | } | ||
| 48 | 78 | ||||
| @@ -103,11 +133,6 @@ func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays i | |||||
| 103 | visits = sumHistoryVisits(data.Entries) | 133 | visits = sumHistoryVisits(data.Entries) | ||
| 104 | uniqueDays = countUniqueHistoryDays(data.Entries) | 134 | uniqueDays = countUniqueHistoryDays(data.Entries) | ||
| 105 | 135 | ||||
| 106 | - | if data.Analytics != nil { | |||
| 107 | - | entries = data.Analytics.TotalEntries | |||
| 108 | - | visits = data.Analytics.TotalVisits | |||
| 109 | - | uniqueDays = data.Analytics.UniqueDays | |||
| 110 | - | } | |||
| 111 | return entries, visits, uniqueDays | 136 | return entries, visits, uniqueDays | ||
| 112 | } | 137 | } | ||
| 113 | 138 | ||||
| @@ -121,11 +146,12 @@ func historyTimelineEmptyMessage(data historyRenderData) string { | |||||
| 121 | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | 146 | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | ||
| 122 | groups := make([]historyTimelineGroup, 0) | 147 | groups := make([]historyTimelineGroup, 0) | ||
| 123 | indexByDate := map[string]int{} | 148 | indexByDate := map[string]int{} | ||
| 149 | + | now := time.Now().Local() | |||
| 124 | for _, entry := range entries { | 150 | for _, entry := range entries { | ||
| 125 | if entry == nil { | 151 | if entry == nil { | ||
| 126 | continue | 152 | continue | ||
| 127 | } | 153 | } | ||
| 128 | - | dateKey, label := historyDateKeyAndLabel(entry.LastVisited) | 154 | + | dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now) |
| 129 | idx, ok := indexByDate[dateKey] | 155 | idx, ok := indexByDate[dateKey] | ||
| 130 | if !ok { | 156 | if !ok { | ||
| 131 | idx = len(groups) | 157 | idx = len(groups) | ||
| @@ -137,14 +163,14 @@ func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup | |||||
| 137 | return groups | 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 | if ts.IsZero() { | 167 | if ts.IsZero() { | ||
| 142 | return "unknown", "Unknown date" | 168 | return "unknown", "Unknown date" | ||
| 143 | } | 169 | } | ||
| 144 | 170 | ||||
| 145 | local := ts.Local() | 171 | local := ts.Local() | ||
| 146 | dateKey := local.Format("2006-01-02") | 172 | dateKey := local.Format("2006-01-02") | ||
| 147 | - | today := time.Now().Local() | 173 | + | today := now.Local() |
| 148 | todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | 174 | todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | ||
| 149 | yesterdayStart := todayStart.AddDate(0, 0, -1) | 175 | yesterdayStart := todayStart.AddDate(0, 0, -1) | ||
| 150 | entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) | 176 | entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) | ||
| @@ -175,6 +201,8 @@ func sumHistoryVisits(entries []*entity.HistoryEntry) int64 { | |||||
| 175 | if entry == nil { | 201 | if entry == nil { | ||
| 176 | continue | 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 | if entry.VisitCount <= 0 { | 206 | if entry.VisitCount <= 0 { | ||
| 179 | visits++ | 207 | visits++ | ||
| 180 | continue | 208 | continue | ||
| @@ -195,23 +223,6 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||||
| 195 | return int64(len(seen)) | 223 | return int64(len(seen)) | ||
| 196 | } | 224 | } | ||
| 197 | 225 | ||||
| 198 | - | func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry { | |||
| 199 | - | domain = browserurl.CanonicalDomain(domain) | |||
| 200 | - | if domain == "" { | |||
| 201 | - | return entries | |||
| 202 | - | } | |||
| 203 | - | filtered := make([]*entity.HistoryEntry, 0, len(entries)) | |||
| 204 | - | for _, entry := range entries { | |||
| 205 | - | if entry == nil { | |||
| 206 | - | continue | |||
| 207 | - | } | |||
| 208 | - | if browserurl.CanonicalDomain(entry.URL) == domain { | |||
| 209 | - | filtered = append(filtered, entry) | |||
| 210 | - | } | |||
| 211 | - | } | |||
| 212 | - | return filtered | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | func displayHistoryDomain(raw string) string { | 226 | func displayHistoryDomain(raw string) string { | ||
| 216 | return browserurl.DisplayDomain(raw) | 227 | return browserurl.DisplayDomain(raw) | ||
| 217 | } | 228 | } | ||
A internal/ui/systemviews/history_view_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | loc := time.FixedZone("test", 2*60*60) | |||
| 14 | + | now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc) | |||
| 15 | + | ||||
| 16 | + | _, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now) | |||
| 17 | + | _, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now) | |||
| 18 | + | _, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now) | |||
| 19 | + | ||||
| 20 | + | assert.Equal(t, "Today", todayLabel) | |||
| 21 | + | assert.Equal(t, "Yesterday", yesterdayLabel) | |||
| 22 | + | assert.Equal(t, "Mon Apr 20", olderLabel) | |||
| 23 | + | } | |||
M internal/ui/systemviews/href.go +11 -2
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func sanitizeHref(raw string) string { | 8 | func sanitizeHref(raw string) string { | ||
| 9 | trimmed := strings.TrimSpace(raw) | 9 | trimmed := strings.TrimSpace(raw) | ||
| 10 | - | if trimmed == "" { | 10 | + | if trimmed == "" || strings.HasPrefix(trimmed, "//") { |
| 11 | return "#" | 11 | return "#" | ||
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| @@ -24,7 +24,16 @@ func sanitizeHref(raw string) string { | |||||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | switch strings.ToLower(parsed.Scheme) { | 26 | switch strings.ToLower(parsed.Scheme) { | ||
| 27 | - | case "http", "https", "dumb": | 27 | + | case "http", "https": |
| 28 | + | if parsed.Host == "" { | |||
| 29 | + | return "#" | |||
| 30 | + | } | |||
| 31 | + | parsed.Scheme = strings.ToLower(parsed.Scheme) | |||
| 32 | + | return parsed.String() | |||
| 33 | + | case "dumb": | |||
| 34 | + | if parsed.Host == "" && parsed.Opaque == "" { | |||
| 35 | + | return "#" | |||
| 36 | + | } | |||
| 28 | return trimmed | 37 | return trimmed | ||
| 29 | default: | 38 | default: | ||
| 30 | return "#" | 39 | return "#" | ||
M internal/ui/systemviews/href_test.go +18 -0
| @@ -34,6 +34,24 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||||
| 34 | assert.Contains(t, html, `href="https://example.com"`) | 34 | assert.Contains(t, html, `href="https://example.com"`) | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | + | func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) { | |||
| 38 | + | t.Parallel() | |||
| 39 | + | ||||
| 40 | + | assert.Equal(t, "#", sanitizeHref("///evil.com")) | |||
| 41 | + | assert.Equal(t, "#", sanitizeHref("//evil.com")) | |||
| 42 | + | assert.Equal(t, "#", sanitizeHref("https:///evil.com")) | |||
| 43 | + | assert.Equal(t, "#", sanitizeHref("http:evil.com")) | |||
| 44 | + | assert.Equal(t, "https://example.com", sanitizeHref("https://example.com")) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) { | |||
| 48 | + | t.Parallel() | |||
| 49 | + | ||||
| 50 | + | assert.Equal(t, "dumb://history", sanitizeHref("dumb://history")) | |||
| 51 | + | assert.Equal(t, "dumb:history", sanitizeHref("dumb:history")) | |||
| 52 | + | assert.Equal(t, "#", sanitizeHref("dumb:///history")) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 37 | func TestFavoritesHTMLSanitizesTagColors(t *testing.T) { | 55 | func TestFavoritesHTMLSanitizesTagColors(t *testing.T) { | ||
| 38 | t.Parallel() | 56 | t.Parallel() | ||
| 39 | 57 | ||||
M internal/ui/systemviews/prefers_dark_js.go +5 -1
| @@ -16,5 +16,9 @@ var currentPrefersDarkImpl = func() bool { | |||||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | media := matchMedia.Invoke("(prefers-color-scheme: dark)") | 18 | media := matchMedia.Invoke("(prefers-color-scheme: dark)") | ||
| 19 | - | return media.Truthy() && media.Get("matches").Bool() | 19 | + | if !media.Truthy() { |
| 20 | + | return false | |||
| 21 | + | } | |||
| 22 | + | matches := media.Get("matches") | |||
| 23 | + | return matches.Truthy() && matches.Bool() | |||
| 20 | } | 24 | } | ||
M internal/ui/systemviews/render.go +2 -0
| @@ -19,6 +19,8 @@ func renderComponent(ctx context.Context, component templ.Component) (string, er | |||||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | func mustRenderComponent(component templ.Component) string { | 21 | func mustRenderComponent(component templ.Component) string { | ||
| 22 | + | // Render helpers are pure HTML formatting paths; route/action contexts are | |||
| 23 | + | // handled before rendering, so there is no caller context to preserve here. | |||
| 22 | html, err := renderComponent(context.Background(), component) | 24 | html, err := renderComponent(context.Background(), component) | ||
| 23 | if err != nil { | 25 | if err != nil { | ||
| 24 | return errorStateHTML(err.Error()) | 26 | return errorStateHTML(err.Error()) | ||
M internal/ui/systemviews/shell.go +17 -8
| @@ -17,16 +17,28 @@ type kvPair struct { | |||||
| 17 | // fragment. Route views populate this; the app frame wraps it. | 17 | // fragment. Route views populate this; the app frame wraps it. | ||
| 18 | type renderedPage struct { | 18 | type renderedPage struct { | ||
| 19 | route Route | 19 | route Route | ||
| 20 | + | title string | |||
| 20 | subtitle string | 21 | subtitle string | ||
| 21 | body string | 22 | body string | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>, | 25 | // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>, | ||
| 25 | - | // or <body>) that wraps route content in the shared shell. | 26 | + | // or <body>) that wraps route content in the shared shell. page.body is |
| 27 | + | // inserted with templ.Raw, so callers must only pass trusted pre-rendered HTML. | |||
| 26 | func renderAppFrame(page renderedPage, theme shellTheme) string { | 28 | func renderAppFrame(page renderedPage, theme shellTheme) string { | ||
| 27 | return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body))) | 29 | return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body))) | ||
| 28 | } | 30 | } | ||
| 29 | 31 | ||||
| 32 | + | func pageDocumentTitle(page renderedPage) string { | |||
| 33 | + | if title := strings.TrimSpace(page.title); title != "" { | |||
| 34 | + | return title | |||
| 35 | + | } | |||
| 36 | + | if subtitle := strings.TrimSpace(page.subtitle); subtitle != "" { | |||
| 37 | + | return subtitle | |||
| 38 | + | } | |||
| 39 | + | return routeSubtitle(page.route) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 30 | func appRootClass(theme shellTheme) string { | 42 | func appRootClass(theme shellTheme) string { | ||
| 31 | themeClass := theme.RootClass | 43 | themeClass := theme.RootClass | ||
| 32 | if themeClass == "" { | 44 | if themeClass == "" { | ||
| @@ -78,6 +90,7 @@ func listHTML(rows, emptyMessage string) string { | |||||
| 78 | return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows) | 90 | return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows) | ||
| 79 | } | 91 | } | ||
| 80 | 92 | ||||
| 93 | + | // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item. | |||
| 81 | func listRowHTML(inner string) string { | 94 | func listRowHTML(inner string) string { | ||
| 82 | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | 95 | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | ||
| 83 | } | 96 | } | ||
| @@ -109,15 +122,11 @@ func kvRowHTML(label, value string) string { | |||||
| 109 | return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value)) | 122 | return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value)) | ||
| 110 | } | 123 | } | ||
| 111 | 124 | ||||
| 112 | - | // sectionHTML wraps content in a styled section with a heading. | 125 | + | // sectionHTML wraps content in a styled section with a heading. inner is |
| 126 | + | // passed through as raw HTML, so callers must provide trusted markup. | |||
| 113 | func sectionHTML(class, title, inner string) string { | 127 | func sectionHTML(class, title, inner string) string { | ||
| 114 | - | classes := "sv-section" | |||
| 115 | - | if strings.TrimSpace(class) != "" { | |||
| 116 | - | classes += " " + strings.TrimSpace(class) | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`, | 128 | return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`, | ||
| 120 | - | html.EscapeString(classes), | 129 | + | sectionClass(class), |
| 121 | html.EscapeString(title), | 130 | html.EscapeString(title), | ||
| 122 | inner, | 131 | inner, | ||
| 123 | ) | 132 | ) | ||
M internal/ui/systemviews/shell_test.go +47 -1
| @@ -26,8 +26,9 @@ func TestRenderAppFrame_ReturnsFragment(t *testing.T) { | |||||
| 26 | // Root div carries theme class. | 26 | // Root div carries theme class. | ||
| 27 | assert.Contains(t, out, `class="sv-app sv-dark"`) | 27 | assert.Contains(t, out, `class="sv-app sv-dark"`) | ||
| 28 | 28 | ||||
| 29 | - | // Route is on the root element. | 29 | + | // Route and document title are on the root element. |
| 30 | assert.Contains(t, out, `data-route="history"`) | 30 | assert.Contains(t, out, `data-route="history"`) | ||
| 31 | + | assert.Contains(t, out, `data-page-title="History"`) | |||
| 31 | 32 | ||||
| 32 | // Shell wrapper present. | 33 | // Shell wrapper present. | ||
| 33 | assert.Contains(t, out, `class="sv-shell"`) | 34 | assert.Contains(t, out, `class="sv-shell"`) | ||
| @@ -52,6 +53,23 @@ func TestRenderAppFrame_DefaultThemeClass(t *testing.T) { | |||||
| 52 | 53 | ||||
| 53 | assert.Contains(t, out, `class="sv-app sv-dark"`) | 54 | assert.Contains(t, out, `class="sv-app sv-dark"`) | ||
| 54 | assert.Contains(t, out, `data-route="favorites"`) | 55 | assert.Contains(t, out, `data-route="favorites"`) | ||
| 56 | + | assert.Contains(t, out, `data-page-title="Favorites"`) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestRenderAppFrame_DocumentTitleUsesExplicitTitle(t *testing.T) { | |||
| 60 | + | t.Parallel() | |||
| 61 | + | ||||
| 62 | + | page := renderedPage{ | |||
| 63 | + | route: RouteHistory, | |||
| 64 | + | title: "History — 12 entries", | |||
| 65 | + | subtitle: "Recent visits", | |||
| 66 | + | body: `<p>content</p>`, | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | out := renderAppFrame(page, shellTheme{}) | |||
| 70 | + | ||||
| 71 | + | assert.Contains(t, out, `data-page-title="History — 12 entries"`) | |||
| 72 | + | assert.Contains(t, out, "Recent visits") | |||
| 55 | } | 73 | } | ||
| 56 | 74 | ||||
| 57 | func TestRenderAppFrame_InlineVarsApplied(t *testing.T) { | 75 | func TestRenderAppFrame_InlineVarsApplied(t *testing.T) { | ||
| @@ -112,6 +130,34 @@ func TestKVHTMLRendersPairs(t *testing.T) { | |||||
| 112 | assert.Contains(t, out, "webkit") | 130 | assert.Contains(t, out, "webkit") | ||
| 113 | } | 131 | } | ||
| 114 | 132 | ||||
| 133 | + | func TestKVHTMLEmptySlice(t *testing.T) { | |||
| 134 | + | t.Parallel() | |||
| 135 | + | ||||
| 136 | + | out := kvHTML(nil) | |||
| 137 | + | ||||
| 138 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 139 | + | assert.Contains(t, out, "No configuration details") | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestKVHTMLAllEmptyRows(t *testing.T) { | |||
| 143 | + | t.Parallel() | |||
| 144 | + | ||||
| 145 | + | out := kvHTML([]kvPair{{}, {Label: " ", Value: ""}}) | |||
| 146 | + | ||||
| 147 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 148 | + | assert.Contains(t, out, "No configuration details") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestErrorStateHTML(t *testing.T) { | |||
| 152 | + | t.Parallel() | |||
| 153 | + | ||||
| 154 | + | out := errorStateHTML("boom") | |||
| 155 | + | ||||
| 156 | + | assert.Contains(t, out, `class="sv-error"`) | |||
| 157 | + | assert.Contains(t, out, `role="alert"`) | |||
| 158 | + | assert.Contains(t, out, "boom") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 115 | func TestSectionHTML(t *testing.T) { | 161 | func TestSectionHTML(t *testing.T) { | ||
| 116 | t.Parallel() | 162 | t.Parallel() | ||
| 117 | 163 | ||||
M internal/ui/systemviews/theme.go +112 -12
| @@ -3,6 +3,7 @@ package systemviews | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | + | "unicode" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | ) | 9 | ) | ||
| @@ -17,6 +18,15 @@ const ( | |||||
| 17 | shellLightClass = "sv-light" | 18 | shellLightClass = "sv-light" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 21 | + | var cssNamedColors = func() map[string]struct{} { | |||
| 22 | + | names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`) | |||
| 23 | + | colors := make(map[string]struct{}, len(names)) | |||
| 24 | + | for _, name := range names { | |||
| 25 | + | colors[name] = struct{}{} | |||
| 26 | + | } | |||
| 27 | + | return colors | |||
| 28 | + | }() | |||
| 29 | + | ||||
| 20 | func currentPrefersDark() bool { | 30 | func currentPrefersDark() bool { | ||
| 21 | return currentPrefersDarkImpl() | 31 | return currentPrefersDarkImpl() | ||
| 22 | } | 32 | } | ||
| @@ -54,23 +64,24 @@ func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme { | |||||
| 54 | 64 | ||||
| 55 | func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string { | 65 | func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string { | ||
| 56 | parts := make([]string, 0, 11) | 66 | parts := make([]string, 0, 11) | ||
| 57 | - | appendInlineVar := func(name, value string) { | 67 | + | appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) { |
| 58 | - | if strings.TrimSpace(value) == "" { | 68 | + | value, ok := sanitize(value) |
| 69 | + | if !ok { | |||
| 59 | return | 70 | return | ||
| 60 | } | 71 | } | ||
| 61 | parts = append(parts, fmt.Sprintf("%s: %s;", name, value)) | 72 | parts = append(parts, fmt.Sprintf("%s: %s;", name, value)) | ||
| 62 | } | 73 | } | ||
| 63 | 74 | ||||
| 64 | - | appendInlineVar("--sv-background", palette.Background) | 75 | + | appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor) |
| 65 | - | appendInlineVar("--sv-surface", palette.Surface) | 76 | + | appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor) |
| 66 | - | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant) | 77 | + | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor) |
| 67 | - | appendInlineVar("--sv-text", palette.Text) | 78 | + | appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor) |
| 68 | - | appendInlineVar("--sv-muted", palette.Muted) | 79 | + | appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor) |
| 69 | - | appendInlineVar("--sv-accent", palette.Accent) | 80 | + | appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor) |
| 70 | - | appendInlineVar("--sv-border", palette.Border) | 81 | + | appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor) |
| 71 | - | appendInlineVar("--sv-font-sans", appearance.SansFont) | 82 | + | appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily) |
| 72 | - | appendInlineVar("--sv-font-serif", appearance.SerifFont) | 83 | + | appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily) |
| 73 | - | appendInlineVar("--sv-font-mono", appearance.MonospaceFont) | 84 | + | appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily) |
| 74 | if appearance.DefaultFontSize > 0 { | 85 | if appearance.DefaultFontSize > 0 { | ||
| 75 | parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize)) | 86 | parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize)) | ||
| 76 | } | 87 | } | ||
| @@ -78,6 +89,95 @@ func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPa | |||||
| 78 | return strings.Join(parts, " ") | 89 | return strings.Join(parts, " ") | ||
| 79 | } | 90 | } | ||
| 80 | 91 | ||||
| 92 | + | func sanitizeCSSColor(value string) (string, bool) { | |||
| 93 | + | value = strings.TrimSpace(value) | |||
| 94 | + | if value == "" || hasCSSBreakout(value) { | |||
| 95 | + | return "", false | |||
| 96 | + | } | |||
| 97 | + | lower := strings.ToLower(value) | |||
| 98 | + | if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) { | |||
| 99 | + | return value, true | |||
| 100 | + | } | |||
| 101 | + | return "", false | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func sanitizeCSSFontFamily(value string) (string, bool) { | |||
| 105 | + | value = strings.TrimSpace(value) | |||
| 106 | + | if value == "" || hasCSSBreakout(value) { | |||
| 107 | + | return "", false | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | validated := value | |||
| 111 | + | if quote := value[0]; quote == '\'' || quote == '"' { | |||
| 112 | + | if len(value) < 2 || value[len(value)-1] != quote { | |||
| 113 | + | return "", false | |||
| 114 | + | } | |||
| 115 | + | validated = value[1 : len(value)-1] | |||
| 116 | + | if validated == "" || strings.ContainsAny(validated, "'\"") { | |||
| 117 | + | return "", false | |||
| 118 | + | } | |||
| 119 | + | } else if strings.ContainsAny(value, "'\"") { | |||
| 120 | + | return "", false | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | for _, r := range validated { | |||
| 124 | + | switch { | |||
| 125 | + | case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r): | |||
| 126 | + | case strings.ContainsRune(" ,._-", r): | |||
| 127 | + | default: | |||
| 128 | + | return "", false | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | return value, true | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func hasCSSBreakout(value string) bool { | |||
| 135 | + | return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func isHexColor(value string) bool { | |||
| 139 | + | if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 { | |||
| 140 | + | return false | |||
| 141 | + | } | |||
| 142 | + | if value[0] != '#' { | |||
| 143 | + | return false | |||
| 144 | + | } | |||
| 145 | + | for _, r := range value[1:] { | |||
| 146 | + | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { | |||
| 147 | + | return false | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | return true | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func isFunctionalColor(value string) bool { | |||
| 154 | + | prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("} | |||
| 155 | + | matched := false | |||
| 156 | + | for _, prefix := range prefixes { | |||
| 157 | + | if strings.HasPrefix(value, prefix) { | |||
| 158 | + | matched = true | |||
| 159 | + | break | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | if !matched || !strings.HasSuffix(value, ")") { | |||
| 163 | + | return false | |||
| 164 | + | } | |||
| 165 | + | for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") { | |||
| 166 | + | if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || strings.ContainsRune(" .,%/-", r)) { | |||
| 167 | + | return false | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | return true | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func isNamedColor(value string) bool { | |||
| 174 | + | if value == "transparent" || value == "currentcolor" { | |||
| 175 | + | return true | |||
| 176 | + | } | |||
| 177 | + | _, ok := cssNamedColors[value] | |||
| 178 | + | return ok | |||
| 179 | + | } | |||
| 180 | + | ||||
| 81 | func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool { | 181 | func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool { | ||
| 82 | return appearance.ColorScheme == "" && | 182 | return appearance.ColorScheme == "" && | ||
| 83 | appearance.SansFont == "" && | 183 | appearance.SansFont == "" && | ||
M internal/ui/systemviews/theme_test.go +60 -0
| @@ -7,6 +7,66 @@ import ( | |||||
| 7 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | vars := buildInlineVars(port.WebUIAppearanceConfig{ | |||
| 14 | + | SansFont: "Inter; color:red", | |||
| 15 | + | SerifFont: "Georgia", | |||
| 16 | + | MonospaceFont: "JetBrains Mono", | |||
| 17 | + | LightPalette: port.ColorPalette{ | |||
| 18 | + | Background: "#ffffff; color:red", | |||
| 19 | + | Surface: "rgb(1 2 3 / 50%)", | |||
| 20 | + | Text: "transparent", | |||
| 21 | + | Accent: "definitelynotacolor", | |||
| 22 | + | Border: "#ABCDEF80", | |||
| 23 | + | }, | |||
| 24 | + | }, port.ColorPalette{ | |||
| 25 | + | Background: "#ffffff; color:red", | |||
| 26 | + | Surface: "rgb(1 2 3 / 50%)", | |||
| 27 | + | Text: "transparent", | |||
| 28 | + | Accent: "definitelynotacolor", | |||
| 29 | + | Border: "#ABCDEF80", | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | assert.NotContains(t, vars, "color:red") | |||
| 33 | + | assert.NotContains(t, vars, "--sv-background") | |||
| 34 | + | assert.NotContains(t, vars, "--sv-font-sans") | |||
| 35 | + | assert.Contains(t, vars, "--sv-surface: rgb(1 2 3 / 50%);") | |||
| 36 | + | assert.Contains(t, vars, "--sv-text: transparent;") | |||
| 37 | + | assert.NotContains(t, vars, "definitelynotacolor") | |||
| 38 | + | assert.Contains(t, vars, "--sv-border: #ABCDEF80;") | |||
| 39 | + | assert.Contains(t, vars, "--sv-font-serif: Georgia;") | |||
| 40 | + | assert.Contains(t, vars, "--sv-font-mono: JetBrains Mono;") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestSanitizeCSSFontFamilyQuotes(t *testing.T) { | |||
| 44 | + | t.Parallel() | |||
| 45 | + | ||||
| 46 | + | tests := []struct { | |||
| 47 | + | name string | |||
| 48 | + | value string | |||
| 49 | + | want bool | |||
| 50 | + | }{ | |||
| 51 | + | {name: "unquoted spaces", value: "JetBrains Mono", want: true}, | |||
| 52 | + | {name: "single quoted token", value: "'JetBrains Mono'", want: true}, | |||
| 53 | + | {name: "double quoted token", value: "\"JetBrains Mono\"", want: true}, | |||
| 54 | + | {name: "unquoted single quote", value: "JetBrains' Mono", want: false}, | |||
| 55 | + | {name: "unquoted double quote", value: "JetBrains\" Mono", want: false}, | |||
| 56 | + | {name: "internal quote", value: "'JetBrains' Mono'", want: false}, | |||
| 57 | + | {name: "breakout", value: "'JetBrains Mono'; color:red", want: false}, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | ||||
| 64 | + | _, got := sanitizeCSSFontFamily(tt.value) | |||
| 65 | + | assert.Equal(t, tt.want, got) | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 10 | func TestResolveShellTheme(t *testing.T) { | 70 | func TestResolveShellTheme(t *testing.T) { | ||
| 11 | orig := currentPrefersDarkImpl | 71 | orig := currentPrefersDarkImpl | ||
| 12 | t.Cleanup(func() { | 72 | t.Cleanup(func() { | ||