Merge pull request #235 from bnema/feat/systemviews-wasm
Replace webui with WASM systemviews
260 files · +21413 -17442
M .github/dependabot.yml +0 -9
| @@ -9,15 +9,6 @@ updates: | |||||
| 9 | patterns: | 9 | patterns: | ||
| 10 | - "*" | 10 | - "*" | ||
| 11 | 11 | ||||
| 12 | - | - package-ecosystem: npm | |||
| 13 | - | directory: /webui | |||
| 14 | - | schedule: | |||
| 15 | - | interval: weekly | |||
| 16 | - | groups: | |||
| 17 | - | npm-dependencies: | |||
| 18 | - | patterns: | |||
| 19 | - | - "*" | |||
| 20 | - | ||||
| 21 | - package-ecosystem: github-actions | 12 | - package-ecosystem: github-actions | ||
| 22 | directory: / | 13 | directory: / | ||
| 23 | schedule: | 14 | schedule: | ||
M .github/workflows/aur.yml +1 -1
| @@ -51,7 +51,7 @@ jobs: | |||||
| 51 | "arch=('x86_64')" \ | 51 | "arch=('x86_64')" \ | ||
| 52 | 'url="https://github.com/bnema/dumber"' \ | 52 | 'url="https://github.com/bnema/dumber"' \ | ||
| 53 | "license=('MIT')" \ | 53 | "license=('MIT')" \ | ||
| 54 | - | "makedepends=('go>=1.25' 'npm' 'git')" \ | 54 | + | "makedepends=('go>=1.26' 'git' 'brotli')" \ |
| 55 | "depends=('gtk4' 'webkitgtk-6.0')" \ | 55 | "depends=('gtk4' 'webkitgtk-6.0')" \ | ||
| 56 | 'optdepends=(' \ | 56 | 'optdepends=(' \ | ||
| 57 | " 'gst-plugins-base: Base media codecs'" \ | 57 | " 'gst-plugins-base: Base media codecs'" \ | ||
M .github/workflows/ci.yml +8 -22
| @@ -7,11 +7,11 @@ on: | |||||
| 7 | - '**.go' | 7 | - '**.go' | ||
| 8 | - 'go.mod' | 8 | - 'go.mod' | ||
| 9 | - 'go.sum' | 9 | - 'go.sum' | ||
| 10 | - | - 'webui/**' | |||
| 11 | - '.golangci.yml' | 10 | - '.golangci.yml' | ||
| 12 | - 'Makefile' | 11 | - 'Makefile' | ||
| 13 | - 'sqlc.yaml' | 12 | - 'sqlc.yaml' | ||
| 14 | - 'assets/**' | 13 | - 'assets/**' | ||
| 14 | + | - '.github/workflows/**' | |||
| 15 | pull_request: | 15 | pull_request: | ||
| 16 | 16 | ||||
| 17 | jobs: | 17 | jobs: | ||
| @@ -31,11 +31,11 @@ jobs: | |||||
| 31 | - '**.go' | 31 | - '**.go' | ||
| 32 | - 'go.mod' | 32 | - 'go.mod' | ||
| 33 | - 'go.sum' | 33 | - 'go.sum' | ||
| 34 | - | - 'webui/**' | |||
| 35 | - '.golangci.yml' | 34 | - '.golangci.yml' | ||
| 36 | - 'Makefile' | 35 | - 'Makefile' | ||
| 37 | - 'sqlc.yaml' | 36 | - 'sqlc.yaml' | ||
| 38 | - 'assets/**' | 37 | - 'assets/**' | ||
| 38 | + | - '.github/workflows/**' | |||
| 39 | 39 | ||||
| 40 | lint: | 40 | lint: | ||
| 41 | needs: changes | 41 | needs: changes | ||
| @@ -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 nodejs npm 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,15 +63,8 @@ jobs: | |||||
| 63 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | 63 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | ||
| 64 | restore-keys: | | 64 | restore-keys: | | ||
| 65 | ${{ runner.os }}-go-build- | 65 | ${{ runner.os }}-go-build- | ||
| 66 | - | - name: Cache npm | 66 | + | - name: Build application and systemviews assets |
| 67 | - | uses: actions/cache@v5 | 67 | + | run: make build |
| 68 | - | with: | |||
| 69 | - | path: ~/.npm | |||
| 70 | - | key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }} | |||
| 71 | - | restore-keys: | | |||
| 72 | - | ${{ runner.os }}-npm- | |||
| 73 | - | - name: Build frontend assets | |||
| 74 | - | run: make build-frontend | |||
| 75 | - name: Install golangci-lint | 68 | - name: Install golangci-lint | ||
| 76 | run: | | 69 | run: | | ||
| 77 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest | 70 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest | ||
| @@ -90,7 +83,7 @@ jobs: | |||||
| 90 | - name: Install dependencies | 83 | - name: Install dependencies | ||
| 91 | run: | | 84 | run: | | ||
| 92 | pacman -Syu --noconfirm | 85 | pacman -Syu --noconfirm | ||
| 93 | - | pacman -S --noconfirm git go nodejs npm 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 |
| 94 | - uses: actions/checkout@v6 | 87 | - uses: actions/checkout@v6 | ||
| 95 | - name: Cache Go modules | 88 | - name: Cache Go modules | ||
| 96 | uses: actions/cache@v5 | 89 | uses: actions/cache@v5 | ||
| @@ -106,15 +99,8 @@ jobs: | |||||
| 106 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | 99 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} | ||
| 107 | restore-keys: | | 100 | restore-keys: | | ||
| 108 | ${{ runner.os }}-go-build- | 101 | ${{ runner.os }}-go-build- | ||
| 109 | - | - name: Cache npm | 102 | + | - name: Build application and systemviews assets |
| 110 | - | uses: actions/cache@v5 | 103 | + | run: make build |
| 111 | - | with: | |||
| 112 | - | path: ~/.npm | |||
| 113 | - | key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }} | |||
| 114 | - | restore-keys: | | |||
| 115 | - | ${{ runner.os }}-npm- | |||
| 116 | - | - name: Build frontend assets | |||
| 117 | - | run: make build-frontend | |||
| 118 | - name: Run tests | 104 | - name: Run tests | ||
| 119 | env: | 105 | env: | ||
| 120 | GDK_BACKEND: wayland | 106 | GDK_BACKEND: wayland | ||
D .github/workflows/webui.yml +0 -30
| @@ -1,30 +0,0 @@ | |||||
| 1 | - | name: WebUI CI | |||
| 2 | - | ||||
| 3 | - | on: | |||
| 4 | - | push: | |||
| 5 | - | branches: [dev, main] | |||
| 6 | - | paths: | |||
| 7 | - | - 'webui/**' | |||
| 8 | - | pull_request: | |||
| 9 | - | paths: | |||
| 10 | - | - 'webui/**' | |||
| 11 | - | ||||
| 12 | - | jobs: | |||
| 13 | - | check: | |||
| 14 | - | runs-on: ubuntu-latest | |||
| 15 | - | steps: | |||
| 16 | - | - uses: actions/checkout@v6 | |||
| 17 | - | ||||
| 18 | - | - uses: actions/setup-node@v6 | |||
| 19 | - | with: | |||
| 20 | - | node-version: '24' | |||
| 21 | - | cache: 'npm' | |||
| 22 | - | cache-dependency-path: 'webui/package-lock.json' | |||
| 23 | - | ||||
| 24 | - | - name: Install dependencies | |||
| 25 | - | run: npm ci | |||
| 26 | - | working-directory: webui | |||
| 27 | - | ||||
| 28 | - | - name: Run checks | |||
| 29 | - | run: npm run check | |||
| 30 | - | working-directory: webui | |||
M .gitignore +3 -0
| @@ -64,6 +64,9 @@ tmp/go-cache/ | |||||
| 64 | tmp/go-mod/ | 64 | tmp/go-mod/ | ||
| 65 | tmp/ | 65 | tmp/ | ||
| 66 | assets/webui | 66 | assets/webui | ||
| 67 | + | assets/systemviews/systemviews.wasm | |||
| 68 | + | assets/systemviews/systemviews.wasm.br | |||
| 69 | + | assets/systemviews/wasm_exec.js | |||
| 67 | 70 | ||||
| 68 | # Node/TypeScript related | 71 | # Node/TypeScript related | ||
| 69 | node_modules/ | 72 | node_modules/ | ||
M .goreleaser.yaml +2 -2
| @@ -4,8 +4,8 @@ version: 2 | |||||
| 4 | 4 | ||||
| 5 | before: | 5 | before: | ||
| 6 | hooks: | 6 | hooks: | ||
| 7 | - | # Build frontend assets before building binaries | 7 | + | # Build systemviews assets before building binaries |
| 8 | - | - make build-frontend | 8 | + | - make build-systemviews |
| 9 | 9 | ||||
| 10 | builds: | 10 | builds: | ||
| 11 | # Pure Go build (no CGO required) | 11 | # Pure Go build (no CGO required) | ||
M .mockery.yaml +1 -0
| @@ -88,6 +88,7 @@ packages: | |||||
| 88 | UpdateChecker: {} | 88 | UpdateChecker: {} | ||
| 89 | UpdateDownloader: {} | 89 | UpdateDownloader: {} | ||
| 90 | WebUIConfigSaver: {} | 90 | WebUIConfigSaver: {} | ||
| 91 | + | SystemviewConfigReader: {} | |||
| 91 | XDGPaths: {} | 92 | XDGPaths: {} | ||
| 92 | github.com/bnema/dumber/internal/domain/repository: | 93 | github.com/bnema/dumber/internal/domain/repository: | ||
| 93 | config: | 94 | config: | ||
M CONTRIBUTING.md +3 -4
| @@ -34,10 +34,9 @@ git push origin my-fix | |||||
| 34 | 2. Run `make test` and ensure tests pass | 34 | 2. Run `make test` and ensure tests pass | ||
| 35 | 3. Run `go fmt` on any Go code changes | 35 | 3. Run `go fmt` on any Go code changes | ||
| 36 | 36 | ||||
| 37 | - | **WebUI code (in `webui/`):** | 37 | + | **Systemviews assets:** |
| 38 | - | 1. Run `npm run lint` to fix linting issues | 38 | + | 1. If you modified systemviews files such as `cmd/systemviews/` or `.templ` components, run `make build-systemviews` to regenerate embedded WASM assets |
| 39 | - | 2. Run `npm run fmt` to format with Prettier | 39 | + | 2. Run `make check` to verify build, tests, and tools before committing |
| 40 | - | 3. Run `npm run check` to run svelte-check | |||
| 41 | 40 | ||||
| 42 | Keep commits focused and use single-line commit messages. | 41 | Keep commits focused and use single-line commit messages. | ||
| 43 | 42 | ||||
M Makefile +22 -14
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-frontend build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check | 3 | + | .PHONY: build build-systemviews generate-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -39,22 +39,31 @@ help: ## Show this help message | |||||
| 39 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | 39 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | ||
| 40 | 40 | ||||
| 41 | # Build targets | 41 | # Build targets | ||
| 42 | - | build: build-frontend ## Build the application (pure Go, no CGO) | 42 | + | build: build-systemviews ## Build the application (pure Go, no CGO) |
| 43 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | 43 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | ||
| 44 | @mkdir -p $(DIST_DIR) | 44 | @mkdir -p $(DIST_DIR) | ||
| 45 | - | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 45 | + | CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 46 | @rm -f $(DIST_DIR)/cef-helper | 46 | @rm -f $(DIST_DIR)/cef-helper | ||
| 47 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 47 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 48 | 48 | ||||
| 49 | - | build-frontend: ## Build homepage and error pages | 49 | + | generate-systemviews: ## Generate Go code from systemviews templ components |
| 50 | - | @echo "Building webui pages (homepage + error)..." | 50 | + | @echo "Generating systemviews templ components..." |
| 51 | - | @cd webui && npm install --silent && npm run build | 51 | + | go tool templ generate -path internal/ui/systemviews -include-version=false |
| 52 | - | @echo "Frontend build complete" | 52 | + | @echo "Systemviews templ generation complete" |
| 53 | - | 53 | + | ||
| 54 | - | build-quick: ## Build without frontend (faster for backend development) | 54 | + | build-systemviews: generate-systemviews ## Build the WASM systemviews runtime |
| 55 | - | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | 55 | + | @echo "Building systemviews wasm assets..." |
| 56 | + | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | |||
| 57 | + | @mkdir -p assets/systemviews | |||
| 58 | + | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |||
| 59 | + | GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |||
| 60 | + | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | |||
| 61 | + | @echo "Systemviews build complete" | |||
| 62 | + | ||||
| 63 | + | build-quick: ## Build quickly for backend development | |||
| 64 | + | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |||
| 56 | @mkdir -p $(DIST_DIR) | 65 | @mkdir -p $(DIST_DIR) | ||
| 57 | - | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 66 | + | CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 58 | @rm -f $(DIST_DIR)/cef-helper | 67 | @rm -f $(DIST_DIR)/cef-helper | ||
| 59 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 68 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 60 | 69 | ||||
| @@ -137,8 +146,7 @@ clean: ## Clean build artifacts | |||||
| 137 | @echo "Cleaning build artifacts..." | 146 | @echo "Cleaning build artifacts..." | ||
| 138 | rm -rf $(DIST_DIR) | 147 | rm -rf $(DIST_DIR) | ||
| 139 | rm -f $(BINARY_NAME) | 148 | rm -f $(BINARY_NAME) | ||
| 140 | - | rm -rf webui/dist webui/node_modules | 149 | + | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br |
| 141 | - | rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css | |||
| 142 | rm -f coverage.out coverage.html | 150 | rm -f coverage.out coverage.html | ||
| 143 | go clean -cache | 151 | go clean -cache | ||
| 144 | go clean -testcache | 152 | go clean -testcache | ||
| @@ -174,7 +182,7 @@ man: build-quick ## Install man pages to ~/.local/share/man/man1/ | |||||
| 174 | # Native release targets | 182 | # Native release targets | ||
| 175 | .PHONY: release-snapshot release | 183 | .PHONY: release-snapshot release | ||
| 176 | 184 | ||||
| 177 | - | release-snapshot: build-frontend ## Build snapshot using goreleaser | 185 | + | release-snapshot: build-systemviews ## Build snapshot using goreleaser |
| 178 | @echo "Building snapshot with goreleaser..." | 186 | @echo "Building snapshot with goreleaser..." | ||
| 179 | goreleaser release --snapshot --clean | 187 | goreleaser release --snapshot --clean | ||
| 180 | 188 | ||||
A assets/systemviews/index.html +71 -0
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="dark light"> | |||
| 7 | + | <title>systemviews</title> | |||
| 8 | + | <link rel="stylesheet" href="./systemviews.css"> | |||
| 9 | + | <script src="./wasm_exec.js"></script> | |||
| 10 | + | <script> | |||
| 11 | + | function renderWasmError(prefixText, err) { | |||
| 12 | + | const render = () => { | |||
| 13 | + | const app = document.getElementById('app'); | |||
| 14 | + | if (!app) return; | |||
| 15 | + | app.innerHTML = ''; | |||
| 16 | + | const message = document.createElement('div'); | |||
| 17 | + | message.className = 'sv-error'; | |||
| 18 | + | const prefix = document.createElement('span'); | |||
| 19 | + | prefix.textContent = prefixText; | |||
| 20 | + | const details = document.createElement('span'); | |||
| 21 | + | details.textContent = err && err.message ? err.message : String(err || 'unsupported browser'); | |||
| 22 | + | message.append(prefix, details); | |||
| 23 | + | app.append(message); | |||
| 24 | + | }; | |||
| 25 | + | if (document.readyState === 'loading' && !document.getElementById('app')) { | |||
| 26 | + | document.addEventListener('DOMContentLoaded', render, { once: true }); | |||
| 27 | + | } else { | |||
| 28 | + | render(); | |||
| 29 | + | } | |||
| 30 | + | console.error(prefixText, err); | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | function instantiateWasm(go, wasmURL) { | |||
| 34 | + | const instantiateFromBytes = () => fetch(wasmURL) | |||
| 35 | + | .then((response) => { | |||
| 36 | + | if (!response.ok) { | |||
| 37 | + | throw new Error(`HTTP ${response.status} loading ${wasmURL}`); | |||
| 38 | + | } | |||
| 39 | + | return response.arrayBuffer(); | |||
| 40 | + | }) | |||
| 41 | + | .then((bytes) => WebAssembly.instantiate(bytes, go.importObject)); | |||
| 42 | + | ||||
| 43 | + | if (!WebAssembly.instantiateStreaming) { | |||
| 44 | + | return instantiateFromBytes(); | |||
| 45 | + | } | |||
| 46 | + | return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject) | |||
| 47 | + | .catch((err) => { | |||
| 48 | + | console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err); | |||
| 49 | + | return instantiateFromBytes(); | |||
| 50 | + | }); | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | if (typeof WebAssembly !== 'object') { | |||
| 54 | + | renderWasmError('WebAssembly is not supported in this browser: '); | |||
| 55 | + | } else if (typeof Go !== 'function') { | |||
| 56 | + | renderWasmError('Go WebAssembly runtime failed to load: '); | |||
| 57 | + | } else { | |||
| 58 | + | const go = new Go(); | |||
| 59 | + | instantiateWasm(go, './systemviews.wasm') | |||
| 60 | + | .then((result) => go.run(result.instance)) | |||
| 61 | + | .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err)); | |||
| 62 | + | } | |||
| 63 | + | </script> | |||
| 64 | + | </head> | |||
| 65 | + | <body> | |||
| 66 | + | <main id="app">Loading systemviews…</main> | |||
| 67 | + | <noscript> | |||
| 68 | + | <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p> | |||
| 69 | + | </noscript> | |||
| 70 | + | </body> | |||
| 71 | + | </html> | |||
A assets/systemviews/systemviews.css +977 -0
| @@ -0,0 +1,977 @@ | |||||
| 1 | + | :root { | |||
| 2 | + | color-scheme: dark; | |||
| 3 | + | --sv-background: #0f1115; | |||
| 4 | + | --sv-surface: #171a21; | |||
| 5 | + | --sv-surface-variant: #1d2129; | |||
| 6 | + | --sv-text: #e8ebf0; | |||
| 7 | + | --sv-muted: #9aa3b2; | |||
| 8 | + | --sv-border: #2a313d; | |||
| 9 | + | --sv-success: var(--sv-accent, #8ab4ff); | |||
| 10 | + | --sv-danger: #ef4444; | |||
| 11 | + | --sv-danger-text: #fecaca; | |||
| 12 | + | --sv-radius-card: 0.5rem; | |||
| 13 | + | --sv-radius-control: 0.35rem; | |||
| 14 | + | --sv-radius-chip: 0.45rem; | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | body { | |||
| 18 | + | margin: 0; | |||
| 19 | + | font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |||
| 20 | + | font-size: var(--sv-font-size, 14px); | |||
| 21 | + | background: var(--sv-background, #0f1115); | |||
| 22 | + | color: var(--sv-text, #e8ebf0); | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | .sv-app { | |||
| 26 | + | min-height: 100vh; | |||
| 27 | + | background: var(--sv-background, #0f1115); | |||
| 28 | + | color: var(--sv-text, #e8ebf0); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | .sv-app.sv-dark { | |||
| 32 | + | color-scheme: dark; | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | .sv-app.sv-light { | |||
| 36 | + | color-scheme: light; | |||
| 37 | + | --sv-background: #f8fafc; | |||
| 38 | + | --sv-surface: #ffffff; | |||
| 39 | + | --sv-surface-variant: #eef2f7; | |||
| 40 | + | --sv-text: #111827; | |||
| 41 | + | --sv-muted: #64748b; | |||
| 42 | + | --sv-border: #d7dee8; | |||
| 43 | + | --sv-primary: #2563eb; | |||
| 44 | + | --sv-danger: #dc2626; | |||
| 45 | + | --sv-danger-text: #7f1d1d; | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | ||||
| 49 | + | .sv-shell { | |||
| 50 | + | display: grid; | |||
| 51 | + | gap: 1rem; | |||
| 52 | + | padding: 1rem; | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | .sv-title { | |||
| 56 | + | display: flex; | |||
| 57 | + | flex-wrap: wrap; | |||
| 58 | + | gap: 0.45rem; | |||
| 59 | + | align-items: baseline; | |||
| 60 | + | margin: 0; | |||
| 61 | + | font-size: clamp(1.35rem, 2.4vw, 2rem); | |||
| 62 | + | line-height: 1.15; | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | .sv-title-detail { | |||
| 66 | + | color: var(--sv-muted); | |||
| 67 | + | font-size: clamp(0.9rem, 1.25vw, 1.05rem); | |||
| 68 | + | font-weight: 600; | |||
| 69 | + | letter-spacing: 0; | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | .sv-content { | |||
| 73 | + | display: grid; | |||
| 74 | + | gap: 1rem; | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | .sv-section > h2 { | |||
| 78 | + | margin: 0 0 0.75rem; | |||
| 79 | + | font-size: 1rem; | |||
| 80 | + | letter-spacing: 0.02em; | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | .sv-list { | |||
| 84 | + | display: grid; | |||
| 85 | + | gap: 0.6rem; | |||
| 86 | + | margin: 0.75rem 0 0; | |||
| 87 | + | padding: 0; | |||
| 88 | + | list-style: none; | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | .sv-list-row { | |||
| 92 | + | padding: 0.75rem; | |||
| 93 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 94 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 95 | + | background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent); | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | .sv-link { | |||
| 99 | + | color: var(--sv-accent, #8ab4ff); | |||
| 100 | + | font-weight: 700; | |||
| 101 | + | text-decoration: none; | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | .sv-link:hover { | |||
| 105 | + | text-decoration: underline; | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | .sv-badge { | |||
| 109 | + | display: inline-flex; | |||
| 110 | + | align-items: center; | |||
| 111 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 112 | + | border-radius: 999px; | |||
| 113 | + | padding: 0.15rem 0.45rem; | |||
| 114 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 115 | + | color: var(--sv-muted, #9aa3b2); | |||
| 116 | + | font-size: 12px; | |||
| 117 | + | font-weight: 700; | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | .sv-header { | |||
| 121 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 122 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 123 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 124 | + | padding: 1rem; | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | .sv-section { | |||
| 128 | + | background: var(--sv-surface, #171a21); | |||
| 129 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 130 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 131 | + | padding: 1rem; | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | .sv-meta { | |||
| 135 | + | color: var(--sv-muted, #9aa3b2); | |||
| 136 | + | font-size: 12px; | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | .sv-group { | |||
| 140 | + | margin-top: 1rem; | |||
| 141 | + | padding: 1rem; | |||
| 142 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 143 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 144 | + | background: var(--sv-surface, #171a21); | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | .sv-group-header { | |||
| 148 | + | display: flex; | |||
| 149 | + | justify-content: space-between; | |||
| 150 | + | gap: 1rem; | |||
| 151 | + | align-items: baseline; | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | .sv-group-header h4 { | |||
| 155 | + | margin: 0; | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | .sv-group-body { | |||
| 159 | + | display: grid; | |||
| 160 | + | gap: 0.75rem; | |||
| 161 | + | margin-top: 0.75rem; | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | .sv-binding { | |||
| 165 | + | padding-top: 0.75rem; | |||
| 166 | + | border-top: 1px solid var(--sv-border, #2a313d); | |||
| 167 | + | display: grid; | |||
| 168 | + | gap: 0.5rem; | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | .sv-group-body > .sv-binding:first-child { | |||
| 172 | + | padding-top: 0; | |||
| 173 | + | border-top: 0; | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | .sv-binding-header { | |||
| 177 | + | display: flex; | |||
| 178 | + | justify-content: space-between; | |||
| 179 | + | gap: 1rem; | |||
| 180 | + | align-items: flex-start; | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | .sv-binding-description { | |||
| 184 | + | font-weight: 600; | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | .sv-binding-keys { | |||
| 188 | + | display: flex; | |||
| 189 | + | flex-wrap: wrap; | |||
| 190 | + | gap: 0.35rem; | |||
| 191 | + | justify-content: flex-end; | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | .sv-key { | |||
| 195 | + | display: inline-flex; | |||
| 196 | + | align-items: center; | |||
| 197 | + | padding: 0.2rem 0.45rem; | |||
| 198 | + | border-radius: var(--sv-radius-control, 0.35rem); | |||
| 199 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 200 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 201 | + | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |||
| 202 | + | font-size: 12px; | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | .sv-binding-meta { | |||
| 206 | + | display: flex; | |||
| 207 | + | flex-wrap: wrap; | |||
| 208 | + | gap: 0.35rem 1rem; | |||
| 209 | + | color: var(--sv-muted, #9aa3b2); | |||
| 210 | + | font-size: 12px; | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | .sv-empty { | |||
| 214 | + | color: var(--sv-muted, #9aa3b2); | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | .sv-loading-panel, | |||
| 218 | + | .sv-loading-card { | |||
| 219 | + | display: inline-flex; | |||
| 220 | + | align-items: center; | |||
| 221 | + | gap: 0.55rem; | |||
| 222 | + | color: var(--sv-muted, #9aa3b2); | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | .sv-loading-panel { | |||
| 226 | + | min-height: 4rem; | |||
| 227 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 228 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 229 | + | padding: 0.85rem; | |||
| 230 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | .sv-spinner { | |||
| 234 | + | width: 1rem; | |||
| 235 | + | height: 1rem; | |||
| 236 | + | border: 2px solid color-mix(in srgb, var(--sv-muted, #9aa3b2) 35%, transparent); | |||
| 237 | + | border-top-color: var(--sv-accent, #8ab4ff); | |||
| 238 | + | border-radius: 999px; | |||
| 239 | + | animation: sv-spin 0.75s linear infinite; | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | .sv-history-load-more { | |||
| 243 | + | display: flex; | |||
| 244 | + | justify-content: center; | |||
| 245 | + | padding: 1rem 0 0.25rem; | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | @keyframes sv-spin { | |||
| 249 | + | to { | |||
| 250 | + | transform: rotate(360deg); | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | .sv-stat-grid { | |||
| 255 | + | display: grid; | |||
| 256 | + | grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); | |||
| 257 | + | gap: 0.75rem; | |||
| 258 | + | margin-top: 0.75rem; | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | .sv-stat-card { | |||
| 262 | + | display: grid; | |||
| 263 | + | gap: 0.15rem; | |||
| 264 | + | padding: 0.75rem; | |||
| 265 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 266 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 267 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | .sv-stat-value { | |||
| 271 | + | color: var(--sv-accent, #8ab4ff); | |||
| 272 | + | font-size: 1.35rem; | |||
| 273 | + | font-weight: 700; | |||
| 274 | + | line-height: 1.05; | |||
| 275 | + | font-variant-numeric: tabular-nums; | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | .sv-stat-label { | |||
| 279 | + | color: var(--sv-muted, #9aa3b2); | |||
| 280 | + | font-size: 12px; | |||
| 281 | + | text-transform: uppercase; | |||
| 282 | + | letter-spacing: 0.08em; | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | .sv-domain-list { | |||
| 286 | + | display: flex; | |||
| 287 | + | flex-wrap: wrap; | |||
| 288 | + | gap: 0.35rem; | |||
| 289 | + | max-height: 4rem; | |||
| 290 | + | margin-top: 0.5rem; | |||
| 291 | + | overflow: hidden; | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | .sv-domain-chip { | |||
| 295 | + | display: inline-flex; | |||
| 296 | + | gap: 0.35rem; | |||
| 297 | + | align-items: center; | |||
| 298 | + | max-width: 13rem; | |||
| 299 | + | min-width: 0; | |||
| 300 | + | padding: 0.25rem 0.35rem 0.25rem 0.5rem; | |||
| 301 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 302 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |||
| 303 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | .sv-domain-chip-main { | |||
| 307 | + | display: inline-flex; | |||
| 308 | + | gap: 0.3rem; | |||
| 309 | + | align-items: baseline; | |||
| 310 | + | min-width: 0; | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | .sv-domain-meta { | |||
| 314 | + | flex: 0 0 auto; | |||
| 315 | + | white-space: nowrap; | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | .sv-domain-meta, | |||
| 319 | + | .sv-history-meta, | |||
| 320 | + | .sv-history-url { | |||
| 321 | + | color: var(--sv-muted, #9aa3b2); | |||
| 322 | + | font-size: 12px; | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | .sv-timeline-group + .sv-timeline-group { | |||
| 326 | + | margin-top: 1rem; | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | .sv-timeline-group h3 { | |||
| 330 | + | margin: 0 0 0.5rem; | |||
| 331 | + | color: var(--sv-muted, #9aa3b2); | |||
| 332 | + | font-size: 12px; | |||
| 333 | + | letter-spacing: 0.08em; | |||
| 334 | + | text-transform: uppercase; | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | .sv-history-item { | |||
| 338 | + | display: grid; | |||
| 339 | + | grid-template-columns: minmax(0, 1fr) auto; | |||
| 340 | + | gap: 1rem; | |||
| 341 | + | align-items: start; | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | .sv-history-content { | |||
| 345 | + | display: grid; | |||
| 346 | + | grid-template-columns: 24px minmax(0, 1fr); | |||
| 347 | + | gap: 0.75rem; | |||
| 348 | + | align-items: start; | |||
| 349 | + | min-width: 0; | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | .sv-history-favicon { | |||
| 353 | + | position: relative; | |||
| 354 | + | display: inline-grid; | |||
| 355 | + | place-items: center; | |||
| 356 | + | width: 24px; | |||
| 357 | + | height: 24px; | |||
| 358 | + | overflow: hidden; | |||
| 359 | + | color: var(--sv-muted, #9aa3b2); | |||
| 360 | + | font-size: 0.75rem; | |||
| 361 | + | font-weight: 800; | |||
| 362 | + | line-height: 1; | |||
| 363 | + | text-transform: uppercase; | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | .sv-history-favicon img { | |||
| 367 | + | position: absolute; | |||
| 368 | + | inset: 0; | |||
| 369 | + | z-index: 1; | |||
| 370 | + | width: 24px; | |||
| 371 | + | height: 24px; | |||
| 372 | + | object-fit: contain; | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | .sv-history-favicon-fallback { | |||
| 376 | + | position: relative; | |||
| 377 | + | z-index: 0; | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | .sv-history-favicon-has-image .sv-history-favicon-fallback { | |||
| 381 | + | visibility: hidden; | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | .sv-history-main { | |||
| 385 | + | min-width: 0; | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | .sv-history-url, | |||
| 389 | + | .sv-history-meta { | |||
| 390 | + | margin: 0.25rem 0 0; | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | .sv-history-url { | |||
| 394 | + | display: -webkit-box; | |||
| 395 | + | max-width: 100%; | |||
| 396 | + | overflow: hidden; | |||
| 397 | + | overflow-wrap: anywhere; | |||
| 398 | + | -webkit-box-orient: vertical; | |||
| 399 | + | -webkit-line-clamp: 2; | |||
| 400 | + | line-clamp: 2; | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | .sv-app.sv-dark .sv-history-meta { | |||
| 404 | + | color: var(--sv-text, #e8ebf0); | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | .sv-error { | |||
| 408 | + | border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d)); | |||
| 409 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21)); | |||
| 410 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 411 | + | color: var(--sv-text, #e8ebf0); | |||
| 412 | + | padding: 1rem; | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | .sv-error p { | |||
| 416 | + | margin: 0; | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | .sv-error p + p { | |||
| 420 | + | margin-top: 0.5rem; | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | .sv-kv { | |||
| 424 | + | margin: 0.75rem 0 0; | |||
| 425 | + | display: grid; | |||
| 426 | + | gap: 0.25rem; | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | .sv-kv-row { | |||
| 430 | + | display: grid; | |||
| 431 | + | grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr); | |||
| 432 | + | gap: 0.25rem 1rem; | |||
| 433 | + | padding: 0.5rem 0; | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | .sv-kv-row + .sv-kv-row { | |||
| 437 | + | border-top: 1px solid var(--sv-border, #2a313d); | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | .sv-kv-row dt, | |||
| 441 | + | .sv-kv-row dd { | |||
| 442 | + | margin: 0; | |||
| 443 | + | min-width: 0; | |||
| 444 | + | overflow-wrap: anywhere; | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | .sv-kv-row dt { | |||
| 448 | + | color: var(--sv-muted, #9aa3b2); | |||
| 449 | + | font-weight: 600; | |||
| 450 | + | } | |||
| 451 | + | .sv-alert { | |||
| 452 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 453 | + | padding: 0.75rem 1rem; | |||
| 454 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 455 | + | transition: opacity 180ms ease, transform 180ms ease; | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | .sv-alert-dismiss { | |||
| 459 | + | opacity: 0; | |||
| 460 | + | transform: translateY(-0.25rem); | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | .sv-alert-success { | |||
| 464 | + | background: color-mix(in srgb, var(--sv-success, #8ab4ff) 14%, var(--sv-surface, #171a21)); | |||
| 465 | + | border-color: color-mix(in srgb, var(--sv-success, #8ab4ff) 62%, var(--sv-border, #2a313d)); | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | .sv-alert-error { | |||
| 469 | + | color: var(--sv-danger-text, #fecaca); | |||
| 470 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21)); | |||
| 471 | + | border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 72%, var(--sv-border, #2a313d)); | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | .sv-toolbar, | |||
| 475 | + | .sv-button-row, | |||
| 476 | + | .sv-active-filters, | |||
| 477 | + | .sv-history-actions-panel, | |||
| 478 | + | .sv-history-row-actions { | |||
| 479 | + | display: flex; | |||
| 480 | + | flex-wrap: wrap; | |||
| 481 | + | gap: 0.5rem; | |||
| 482 | + | align-items: center; | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | .sv-toolbar { | |||
| 486 | + | align-items: end; | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | .sv-history-actions-panel { | |||
| 490 | + | margin-top: 0.75rem; | |||
| 491 | + | justify-content: space-between; | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | .sv-search-label { | |||
| 495 | + | display: grid; | |||
| 496 | + | gap: 0.25rem; | |||
| 497 | + | min-width: min(24rem, 100%); | |||
| 498 | + | color: var(--sv-muted, #9aa3b2); | |||
| 499 | + | font-size: 12px; | |||
| 500 | + | font-weight: 600; | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | .sv-search-label input { | |||
| 504 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 505 | + | border-radius: var(--sv-radius-control, 0.35rem); | |||
| 506 | + | padding: 0.55rem 0.65rem; | |||
| 507 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 508 | + | color: var(--sv-text, #e8ebf0); | |||
| 509 | + | font: inherit; | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | .sv-button { | |||
| 513 | + | box-sizing: border-box; | |||
| 514 | + | display: inline-flex; | |||
| 515 | + | align-items: center; | |||
| 516 | + | justify-content: center; | |||
| 517 | + | gap: 0.4rem; | |||
| 518 | + | min-height: 2rem; | |||
| 519 | + | border: 1px solid var(--sv-accent, #8ab4ff); | |||
| 520 | + | border-radius: var(--sv-radius-control, 0.35rem); | |||
| 521 | + | padding: 0.35rem 0.65rem; | |||
| 522 | + | background: var(--sv-accent, #8ab4ff); | |||
| 523 | + | color: var(--sv-background, #0f1115); | |||
| 524 | + | cursor: pointer; | |||
| 525 | + | font: inherit; | |||
| 526 | + | font-weight: 600; | |||
| 527 | + | text-decoration: none; | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | .sv-button-secondary { | |||
| 531 | + | border-color: var(--sv-border, #2a313d); | |||
| 532 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 533 | + | color: var(--sv-text, #e8ebf0); | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | .sv-button-danger { | |||
| 537 | + | border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d)); | |||
| 538 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129)); | |||
| 539 | + | color: var(--sv-danger-text, #fecaca); | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | .sv-button-danger:hover { | |||
| 543 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 24%, var(--sv-surface-variant, #1d2129)); | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | .sv-button-danger:disabled { | |||
| 547 | + | color: var(--sv-danger-text, #fecaca); | |||
| 548 | + | opacity: 0.7; | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | .sv-button-icon-only { | |||
| 552 | + | width: 2rem; | |||
| 553 | + | min-width: 2rem; | |||
| 554 | + | min-height: 2rem; | |||
| 555 | + | padding: 0.35rem; | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | .sv-danger-link { | |||
| 559 | + | border: 0; | |||
| 560 | + | padding: 0.15rem 0.2rem; | |||
| 561 | + | background: transparent; | |||
| 562 | + | color: var(--sv-danger-text, #fecaca); | |||
| 563 | + | cursor: pointer; | |||
| 564 | + | font: inherit; | |||
| 565 | + | font-weight: 650; | |||
| 566 | + | text-decoration: none; | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | .sv-danger-link:hover { | |||
| 570 | + | color: var(--sv-danger, #ef4444); | |||
| 571 | + | text-decoration: underline; | |||
| 572 | + | } | |||
| 573 | + | ||||
| 574 | + | .sv-button:focus-visible, | |||
| 575 | + | .sv-icon-button:focus-visible, | |||
| 576 | + | .sv-domain-filter:focus-visible, | |||
| 577 | + | .sv-danger-link:focus-visible, | |||
| 578 | + | .sv-filter-chip button:focus-visible, | |||
| 579 | + | .sv-link:focus-visible, | |||
| 580 | + | .sv-search-label input:focus-visible, | |||
| 581 | + | .sv-card-form input:focus-visible, | |||
| 582 | + | .sv-card-form select:focus-visible, | |||
| 583 | + | .sv-inline-form input:focus-visible, | |||
| 584 | + | .sv-inline-form select:focus-visible, | |||
| 585 | + | .sv-fieldset input:focus-visible, | |||
| 586 | + | .sv-fieldset select:focus-visible { | |||
| 587 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |||
| 588 | + | outline-offset: 2px; | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | .sv-button:disabled { | |||
| 592 | + | cursor: not-allowed; | |||
| 593 | + | opacity: 0.45; | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | .sv-filter-chip { | |||
| 597 | + | display: inline-flex; | |||
| 598 | + | gap: 0.35rem; | |||
| 599 | + | align-items: center; | |||
| 600 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 601 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |||
| 602 | + | padding: 0.25rem 0.55rem; | |||
| 603 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | .sv-filter-chip button, | |||
| 607 | + | .sv-domain-filter, | |||
| 608 | + | .sv-domain-delete, | |||
| 609 | + | .sv-icon-button { | |||
| 610 | + | border: 0; | |||
| 611 | + | background: transparent; | |||
| 612 | + | color: inherit; | |||
| 613 | + | cursor: pointer; | |||
| 614 | + | font: inherit; | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | .sv-domain-filter { | |||
| 618 | + | min-width: 0; | |||
| 619 | + | overflow: hidden; | |||
| 620 | + | color: var(--sv-text, #e8ebf0); | |||
| 621 | + | font-weight: 700; | |||
| 622 | + | padding: 0; | |||
| 623 | + | text-align: left; | |||
| 624 | + | text-overflow: ellipsis; | |||
| 625 | + | white-space: nowrap; | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | .sv-domain-delete { | |||
| 629 | + | display: inline-grid; | |||
| 630 | + | place-items: center; | |||
| 631 | + | flex: 0 0 auto; | |||
| 632 | + | width: 1rem; | |||
| 633 | + | height: 1rem; | |||
| 634 | + | padding: 0; | |||
| 635 | + | border-radius: 999px; | |||
| 636 | + | color: var(--sv-danger-text, #fecaca); | |||
| 637 | + | font-size: 0.85rem; | |||
| 638 | + | font-weight: 700; | |||
| 639 | + | line-height: 1; | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | .sv-domain-delete:hover { | |||
| 643 | + | background: color-mix(in srgb, var(--sv-danger, #ef4444) 18%, transparent); | |||
| 644 | + | } | |||
| 645 | + | ||||
| 646 | + | .sv-icon-button { | |||
| 647 | + | display: inline-flex; | |||
| 648 | + | align-items: center; | |||
| 649 | + | justify-content: center; | |||
| 650 | + | width: 1.75rem; | |||
| 651 | + | height: 1.75rem; | |||
| 652 | + | border-radius: 999px; | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | .sv-icon { | |||
| 656 | + | flex: 0 0 auto; | |||
| 657 | + | width: 1rem; | |||
| 658 | + | height: 1rem; | |||
| 659 | + | stroke-width: 2.15; | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | .sv-icon-button .sv-icon { | |||
| 663 | + | width: 0.95rem; | |||
| 664 | + | height: 0.95rem; | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | .sv-sr-only { | |||
| 668 | + | position: absolute; | |||
| 669 | + | width: 1px; | |||
| 670 | + | height: 1px; | |||
| 671 | + | padding: 0; | |||
| 672 | + | margin: -1px; | |||
| 673 | + | overflow: hidden; | |||
| 674 | + | -webkit-clip-path: inset(50%); | |||
| 675 | + | clip-path: inset(50%); | |||
| 676 | + | white-space: nowrap; | |||
| 677 | + | border: 0; | |||
| 678 | + | } | |||
| 679 | + | ||||
| 680 | + | .sv-danger { | |||
| 681 | + | color: var(--sv-danger-text, #fecaca); | |||
| 682 | + | } | |||
| 683 | + | ||||
| 684 | + | .sv-icon-button:hover, | |||
| 685 | + | .sv-filter-chip button:hover, | |||
| 686 | + | .sv-domain-filter:hover { | |||
| 687 | + | background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent); | |||
| 688 | + | } | |||
| 689 | + | ||||
| 690 | + | .sv-confirm-backdrop { | |||
| 691 | + | position: fixed; | |||
| 692 | + | inset: 0; | |||
| 693 | + | z-index: 1000; | |||
| 694 | + | display: grid; | |||
| 695 | + | place-items: center; | |||
| 696 | + | padding: 1rem; | |||
| 697 | + | background: rgba(0, 0, 0, 0.55); | |||
| 698 | + | backdrop-filter: blur(2px); | |||
| 699 | + | } | |||
| 700 | + | ||||
| 701 | + | .sv-confirm-dialog { | |||
| 702 | + | width: min(26rem, 100%); | |||
| 703 | + | border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 48%, var(--sv-border, #2a313d)); | |||
| 704 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 705 | + | padding: 1rem; | |||
| 706 | + | background: var(--sv-surface, #171a21); | |||
| 707 | + | color: var(--sv-text, #e8ebf0); | |||
| 708 | + | box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35); | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | .sv-confirm-dialog h3 { | |||
| 712 | + | margin: 0; | |||
| 713 | + | font-size: 1rem; | |||
| 714 | + | } | |||
| 715 | + | ||||
| 716 | + | .sv-confirm-dialog p { | |||
| 717 | + | margin: 0.65rem 0 0; | |||
| 718 | + | color: var(--sv-muted, #9aa3b2); | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | .sv-confirm-actions { | |||
| 722 | + | display: flex; | |||
| 723 | + | justify-content: flex-end; | |||
| 724 | + | gap: 0.5rem; | |||
| 725 | + | margin-top: 1rem; | |||
| 726 | + | } | |||
| 727 | + | ||||
| 728 | + | .sv-history-item.sv-focused { | |||
| 729 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |||
| 730 | + | outline-offset: 0.35rem; | |||
| 731 | + | border-radius: var(--sv-radius-control, 0.35rem); | |||
| 732 | + | } | |||
| 733 | + | ||||
| 734 | + | .sv-key-hints kbd { | |||
| 735 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 736 | + | border-radius: 0.25rem; | |||
| 737 | + | padding: 0.1rem 0.3rem; | |||
| 738 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 739 | + | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |||
| 740 | + | } | |||
| 741 | + | ||||
| 742 | + | .sv-card-form, | |||
| 743 | + | .sv-inline-form { | |||
| 744 | + | display: flex; | |||
| 745 | + | flex-wrap: wrap; | |||
| 746 | + | gap: 0.65rem; | |||
| 747 | + | align-items: end; | |||
| 748 | + | margin-top: 0.75rem; | |||
| 749 | + | } | |||
| 750 | + | ||||
| 751 | + | .sv-card-form { | |||
| 752 | + | padding: 0.75rem; | |||
| 753 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 754 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 755 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | .sv-card-form label, | |||
| 759 | + | .sv-inline-form label, | |||
| 760 | + | .sv-fieldset label { | |||
| 761 | + | display: grid; | |||
| 762 | + | gap: 0.25rem; | |||
| 763 | + | min-width: min(14rem, 100%); | |||
| 764 | + | color: var(--sv-muted, #9aa3b2); | |||
| 765 | + | font-size: 12px; | |||
| 766 | + | font-weight: 600; | |||
| 767 | + | } | |||
| 768 | + | ||||
| 769 | + | .sv-card-form input, | |||
| 770 | + | .sv-card-form select, | |||
| 771 | + | .sv-inline-form input, | |||
| 772 | + | .sv-inline-form select, | |||
| 773 | + | .sv-fieldset input, | |||
| 774 | + | .sv-fieldset select { | |||
| 775 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 776 | + | border-radius: var(--sv-radius-control, 0.35rem); | |||
| 777 | + | padding: 0.5rem 0.6rem; | |||
| 778 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 779 | + | color: var(--sv-text, #e8ebf0); | |||
| 780 | + | font: inherit; | |||
| 781 | + | } | |||
| 782 | + | ||||
| 783 | + | .sv-card-form input[type="color"], | |||
| 784 | + | .sv-inline-form input[type="color"], | |||
| 785 | + | .sv-fieldset input[type="color"] { | |||
| 786 | + | width: 4rem; | |||
| 787 | + | min-width: 4rem; | |||
| 788 | + | height: 2.1rem; | |||
| 789 | + | padding: 0.15rem; | |||
| 790 | + | background: transparent; | |||
| 791 | + | cursor: pointer; | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | .sv-card-form input[type="color"]::-webkit-color-swatch-wrapper, | |||
| 795 | + | .sv-inline-form input[type="color"]::-webkit-color-swatch-wrapper, | |||
| 796 | + | .sv-fieldset input[type="color"]::-webkit-color-swatch-wrapper { | |||
| 797 | + | padding: 0; | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | .sv-card-form input[type="color"]::-webkit-color-swatch, | |||
| 801 | + | .sv-inline-form input[type="color"]::-webkit-color-swatch, | |||
| 802 | + | .sv-fieldset input[type="color"]::-webkit-color-swatch { | |||
| 803 | + | border: 0; | |||
| 804 | + | border-radius: calc(var(--sv-radius-control, 0.35rem) - 0.12rem); | |||
| 805 | + | } | |||
| 806 | + | ||||
| 807 | + | .sv-config-form, | |||
| 808 | + | .sv-fieldset { | |||
| 809 | + | display: flex; | |||
| 810 | + | flex-wrap: wrap; | |||
| 811 | + | gap: 0.65rem; | |||
| 812 | + | align-items: end; | |||
| 813 | + | } | |||
| 814 | + | ||||
| 815 | + | .sv-fieldset { | |||
| 816 | + | width: 100%; | |||
| 817 | + | margin: 0; | |||
| 818 | + | padding: 0.75rem; | |||
| 819 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 820 | + | border-radius: var(--sv-radius-card, 0.5rem); | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | .sv-fieldset legend { | |||
| 824 | + | color: var(--sv-muted, #9aa3b2); | |||
| 825 | + | font-weight: 700; | |||
| 826 | + | padding: 0 0.35rem; | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | .sv-keybinding-row { | |||
| 830 | + | display: grid; | |||
| 831 | + | grid-template-columns: minmax(0, 1fr) minmax(18rem, auto); | |||
| 832 | + | gap: 1rem; | |||
| 833 | + | align-items: start; | |||
| 834 | + | } | |||
| 835 | + | ||||
| 836 | + | .sv-filter-panel { | |||
| 837 | + | display: grid; | |||
| 838 | + | gap: 0.6rem; | |||
| 839 | + | margin-top: 0.75rem; | |||
| 840 | + | } | |||
| 841 | + | ||||
| 842 | + | .sv-history-row-actions .sv-button { | |||
| 843 | + | min-width: 4.75rem; | |||
| 844 | + | } | |||
| 845 | + | ||||
| 846 | + | .sv-history-row-actions .sv-button-icon-only { | |||
| 847 | + | width: 2rem; | |||
| 848 | + | min-width: 2rem; | |||
| 849 | + | flex: 0 0 2rem; | |||
| 850 | + | } | |||
| 851 | + | ||||
| 852 | + | .sv-button-active { | |||
| 853 | + | border-color: var(--sv-accent, #8ab4ff); | |||
| 854 | + | box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset; | |||
| 855 | + | } | |||
| 856 | + | ||||
| 857 | + | .sv-tag-picker { | |||
| 858 | + | display: grid; | |||
| 859 | + | gap: 0.45rem; | |||
| 860 | + | min-width: min(22rem, 100%); | |||
| 861 | + | margin: 0; | |||
| 862 | + | padding: 0; | |||
| 863 | + | border: 0; | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | .sv-tag-picker legend { | |||
| 867 | + | color: var(--sv-muted, #9aa3b2); | |||
| 868 | + | font-size: 12px; | |||
| 869 | + | font-weight: 600; | |||
| 870 | + | } | |||
| 871 | + | ||||
| 872 | + | .sv-card-form .sv-tag-toggle, | |||
| 873 | + | .sv-tag-toggle { | |||
| 874 | + | display: inline-flex; | |||
| 875 | + | min-width: 0; | |||
| 876 | + | color: inherit; | |||
| 877 | + | font-size: inherit; | |||
| 878 | + | font-weight: inherit; | |||
| 879 | + | cursor: pointer; | |||
| 880 | + | } | |||
| 881 | + | ||||
| 882 | + | .sv-tag-toggle input { | |||
| 883 | + | position: absolute; | |||
| 884 | + | inline-size: 1px; | |||
| 885 | + | block-size: 1px; | |||
| 886 | + | opacity: 0; | |||
| 887 | + | pointer-events: none; | |||
| 888 | + | } | |||
| 889 | + | ||||
| 890 | + | .sv-tag-toggle span, | |||
| 891 | + | .sv-tag-button { | |||
| 892 | + | display: inline-flex; | |||
| 893 | + | align-items: center; | |||
| 894 | + | gap: 0.35rem; | |||
| 895 | + | } | |||
| 896 | + | ||||
| 897 | + | .sv-tag-toggle > span { | |||
| 898 | + | min-height: 1.45rem; | |||
| 899 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 900 | + | border-radius: var(--sv-radius-chip, 0.45rem); | |||
| 901 | + | padding: 0.16rem 0.45rem; | |||
| 902 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 903 | + | color: var(--sv-text, #e8ebf0); | |||
| 904 | + | font-size: 12px; | |||
| 905 | + | font-weight: 700; | |||
| 906 | + | } | |||
| 907 | + | ||||
| 908 | + | .sv-tag-toggle input:checked + span, | |||
| 909 | + | .sv-tag-button-active { | |||
| 910 | + | border-color: var(--sv-accent, #8ab4ff); | |||
| 911 | + | background: var(--sv-accent, #8ab4ff); | |||
| 912 | + | color: var(--sv-background, #0f1115); | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | .sv-favorite-item { | |||
| 916 | + | display: grid; | |||
| 917 | + | gap: 0.75rem; | |||
| 918 | + | } | |||
| 919 | + | ||||
| 920 | + | .sv-favorite-header { | |||
| 921 | + | display: flex; | |||
| 922 | + | justify-content: space-between; | |||
| 923 | + | gap: 1rem; | |||
| 924 | + | align-items: start; | |||
| 925 | + | } | |||
| 926 | + | ||||
| 927 | + | .sv-tag-controls { | |||
| 928 | + | display: flex; | |||
| 929 | + | flex-wrap: wrap; | |||
| 930 | + | gap: 0.45rem; | |||
| 931 | + | align-items: center; | |||
| 932 | + | } | |||
| 933 | + | ||||
| 934 | + | .sv-tag-dot { | |||
| 935 | + | display: inline-block; | |||
| 936 | + | width: 0.65rem; | |||
| 937 | + | height: 0.65rem; | |||
| 938 | + | border-radius: 999px; | |||
| 939 | + | margin-right: 0.35rem; | |||
| 940 | + | border: 1px solid color-mix(in srgb, var(--sv-border, #2a313d) 65%, transparent); | |||
| 941 | + | } | |||
| 942 | + | ||||
| 943 | + | .sv-favorite-item.sv-focused { | |||
| 944 | + | outline: 2px solid var(--sv-accent, #8ab4ff); | |||
| 945 | + | outline-offset: 0.35rem; | |||
| 946 | + | border-radius: var(--sv-radius-control, 0.35rem); | |||
| 947 | + | } | |||
| 948 | + | ||||
| 949 | + | @media (max-width: 720px) { | |||
| 950 | + | .sv-favorite-header, | |||
| 951 | + | .sv-keybinding-row, | |||
| 952 | + | .sv-history-item, | |||
| 953 | + | .sv-history-actions-panel, | |||
| 954 | + | .sv-toolbar { | |||
| 955 | + | display: grid; | |||
| 956 | + | grid-template-columns: 1fr; | |||
| 957 | + | } | |||
| 958 | + | ||||
| 959 | + | .sv-kv-row { | |||
| 960 | + | grid-template-columns: 1fr; | |||
| 961 | + | } | |||
| 962 | + | ||||
| 963 | + | .sv-card-form, | |||
| 964 | + | .sv-inline-form, | |||
| 965 | + | .sv-fieldset, | |||
| 966 | + | .sv-button-row { | |||
| 967 | + | align-items: stretch; | |||
| 968 | + | } | |||
| 969 | + | ||||
| 970 | + | .sv-card-form label, | |||
| 971 | + | .sv-inline-form label, | |||
| 972 | + | .sv-fieldset label, | |||
| 973 | + | .sv-button, | |||
| 974 | + | .sv-button-secondary { | |||
| 975 | + | width: 100%; | |||
| 976 | + | } | |||
| 977 | + | } | |||
M assets/webui_embed.go +5 -3
| @@ -4,10 +4,12 @@ import ( | |||||
| 4 | "embed" | 4 | "embed" | ||
| 5 | ) | 5 | ) | ||
| 6 | 6 | ||||
| 7 | - | // WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages). | 7 | + | // WebUIAssets contains the embedded systemviews build output. |
| 8 | - | // Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc. | 8 | + | // The legacy WebUIAssets name is kept for compatibility with existing callers. |
| 9 | + | // Used by the scheme handlers to serve dumb://error, dumb://config, | |||
| 10 | + | // dumb://history, and dumb://favorites. | |||
| 9 | // | 11 | // | ||
| 10 | - | //go:embed webui/* | 12 | + | //go:embed systemviews/* |
| 11 | var WebUIAssets embed.FS | 13 | var WebUIAssets embed.FS | ||
| 12 | 14 | ||||
| 13 | // LogoSVG contains the dumber logo as SVG for desktop icon installation. | 15 | // LogoSVG contains the dumber logo as SVG for desktop icon installation. | ||
M aur/dumber-browser-git/.SRCINFO +0 -1
| @@ -6,7 +6,6 @@ pkgbase = dumber-browser-git | |||||
| 6 | arch = x86_64 | 6 | arch = x86_64 | ||
| 7 | license = MIT | 7 | license = MIT | ||
| 8 | makedepends = go>=1.25 | 8 | makedepends = go>=1.25 | ||
| 9 | - | makedepends = npm | |||
| 10 | makedepends = git | 9 | makedepends = git | ||
| 11 | depends = gtk4 | 10 | depends = gtk4 | ||
| 12 | depends = webkitgtk-6.0 | 11 | depends = webkitgtk-6.0 | ||
M aur/dumber-browser-git/PKGBUILD +1 -1
| @@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)" | |||||
| 6 | arch=('x86_64') | 6 | arch=('x86_64') | ||
| 7 | url="https://github.com/bnema/dumber" | 7 | url="https://github.com/bnema/dumber" | ||
| 8 | license=('MIT') | 8 | license=('MIT') | ||
| 9 | - | makedepends=('go>=1.25' 'npm' 'git') | 9 | + | makedepends=('go>=1.26' 'git' 'brotli') |
| 10 | depends=('gtk4' 'webkitgtk-6.0') | 10 | depends=('gtk4' 'webkitgtk-6.0') | ||
| 11 | optdepends=( | 11 | optdepends=( | ||
| 12 | 'gst-plugins-base: Base media codecs' | 12 | 'gst-plugins-base: Base media codecs' | ||
A cmd/systemviews/bootstrap.go +64 -0
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/ui/systemviews" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type bridgeServices interface { | |||
| 12 | + | port.SystemviewHistoryService | |||
| 13 | + | port.SystemviewFavoritesService | |||
| 14 | + | port.SystemviewConfigService | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App { | |||
| 18 | + | route := systemviews.ParseRoute(locationURI) | |||
| 19 | + | return systemviews.NewApp(systemviews.Dependencies{ | |||
| 20 | + | DOM: dom, | |||
| 21 | + | History: bridge, | |||
| 22 | + | Favorites: bridge, | |||
| 23 | + | Config: bridgeConfigProxy{bridge: bridge, route: route}, | |||
| 24 | + | LocationURI: locationURI, | |||
| 25 | + | }) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type bridgeConfigProxy struct { | |||
| 29 | + | bridge bridgeServices | |||
| 30 | + | route systemviews.Route | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (p bridgeConfigProxy) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 34 | + | return p.bridge.Current(ctx) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (p bridgeConfigProxy) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 38 | + | return p.bridge.Default(ctx) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (p bridgeConfigProxy) Save(ctx context.Context, cfg dto.WebUIConfig) error { | |||
| 42 | + | return p.bridge.Save(ctx, cfg) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 46 | + | // Keybindings are only rendered by dumb://config; skip the bridge load for | |||
| 47 | + | // other systemview routes to keep initial history/favorites loads cheap. | |||
| 48 | + | if p.route != systemviews.RouteConfig { | |||
| 49 | + | return port.KeybindingsConfig{}, nil | |||
| 50 | + | } | |||
| 51 | + | return p.bridge.GetKeybindings(ctx) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) { | |||
| 55 | + | return p.bridge.SetKeybinding(ctx, req) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 59 | + | return p.bridge.ResetKeybinding(ctx, req) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error { | |||
| 63 | + | return p.bridge.ResetAllKeybindings(ctx) | |||
| 64 | + | } | |||
A cmd/systemviews/bootstrap_test.go +258 -0
| @@ -0,0 +1,258 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestNewBridgeApp_WiresHistoryService(t *testing.T) { | |||
| 18 | + | t.Parallel() | |||
| 19 | + | ||||
| 20 | + | bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}} | |||
| 21 | + | app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge) | |||
| 22 | + | ||||
| 23 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 24 | + | assert.True(t, bridge.calledHistory.Load()) | |||
| 25 | + | assert.False(t, bridge.calledFavorites.Load()) | |||
| 26 | + | assert.True(t, bridge.calledConfig.Load()) | |||
| 27 | + | assert.False(t, bridge.calledKeybindings.Load()) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||
| 31 | + | t.Parallel() | |||
| 32 | + | ||||
| 33 | + | bridge := &fakeBridgeService{ | |||
| 34 | + | historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}, | |||
| 35 | + | currentConfig: dto.SystemviewConfigPayload{ | |||
| 36 | + | Appearance: dto.WebUIAppearanceConfig{ | |||
| 37 | + | ColorScheme: "prefer-dark", | |||
| 38 | + | DarkPalette: dto.ColorPalette{ | |||
| 39 | + | Background: "#111111", | |||
| 40 | + | Surface: "#1a1a1a", | |||
| 41 | + | SurfaceVariant: "#2a2a2a", | |||
| 42 | + | Text: "#f5f5f5", | |||
| 43 | + | Muted: "#a0a0a0", | |||
| 44 | + | Accent: "#66aaff", | |||
| 45 | + | Border: "#333333", | |||
| 46 | + | }, | |||
| 47 | + | }, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | dom := &fakeDOM{mounts: make(chan string, 4)} | |||
| 51 | + | app := newBridgeApp(dom, "dumb://history", bridge) | |||
| 52 | + | ||||
| 53 | + | require.NoError(t, app.Run()) | |||
| 54 | + | require.Eventually(t, bridge.calledHistory.Load, time.Second, 10*time.Millisecond) | |||
| 55 | + | assert.True(t, bridge.calledConfig.Load()) | |||
| 56 | + | assert.False(t, bridge.calledKeybindings.Load()) | |||
| 57 | + | html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`) | |||
| 58 | + | assert.Contains(t, html, `class="sv-app sv-dark"`) | |||
| 59 | + | assert.Contains(t, html, `--sv-background: #111111;`) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | |||
| 63 | + | t.Parallel() | |||
| 64 | + | ||||
| 65 | + | bridge := &fakeBridgeService{ | |||
| 66 | + | favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}}, | |||
| 67 | + | folders: []*entity.Folder{{Name: "Read Later"}}, | |||
| 68 | + | tags: []*entity.Tag{{Name: "Go"}}, | |||
| 69 | + | } | |||
| 70 | + | app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge) | |||
| 71 | + | ||||
| 72 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 73 | + | assert.False(t, bridge.calledHistory.Load()) | |||
| 74 | + | assert.True(t, bridge.calledFavorites.Load()) | |||
| 75 | + | assert.True(t, bridge.calledConfig.Load()) | |||
| 76 | + | assert.False(t, bridge.calledKeybindings.Load()) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |||
| 80 | + | t.Parallel() | |||
| 81 | + | ||||
| 82 | + | bridge := &fakeBridgeService{ | |||
| 83 | + | currentConfig: dto.SystemviewConfigPayload{EngineType: "webkit"}, | |||
| 84 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}}, | |||
| 85 | + | } | |||
| 86 | + | app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge) | |||
| 87 | + | ||||
| 88 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 89 | + | assert.False(t, bridge.calledHistory.Load()) | |||
| 90 | + | assert.False(t, bridge.calledFavorites.Load()) | |||
| 91 | + | assert.True(t, bridge.calledConfig.Load()) | |||
| 92 | + | assert.True(t, bridge.calledKeybindings.Load()) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | type fakeDOM struct { | |||
| 96 | + | html string | |||
| 97 | + | mounts chan string | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (f *fakeDOM) Mount(html string) error { | |||
| 101 | + | f.html = html | |||
| 102 | + | if f.mounts != nil { | |||
| 103 | + | select { | |||
| 104 | + | case f.mounts <- html: | |||
| 105 | + | default: | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string { | |||
| 112 | + | t.Helper() | |||
| 113 | + | deadline := time.After(time.Second) | |||
| 114 | + | for { | |||
| 115 | + | select { | |||
| 116 | + | case html := <-mounts: | |||
| 117 | + | matched := true | |||
| 118 | + | for _, value := range values { | |||
| 119 | + | if !strings.Contains(html, value) { | |||
| 120 | + | matched = false | |||
| 121 | + | break | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if matched { | |||
| 125 | + | return html | |||
| 126 | + | } | |||
| 127 | + | case <-deadline: | |||
| 128 | + | t.Fatalf("timed out waiting for DOM mount containing %q", values) | |||
| 129 | + | return "" | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Handwritten fake intentionally tracks state across history, favorites, config, | |||
| 135 | + | // and keybindings boundaries for the composite bootstrap assertions. | |||
| 136 | + | type fakeBridgeService struct { | |||
| 137 | + | calledHistory atomic.Bool | |||
| 138 | + | calledFavorites atomic.Bool | |||
| 139 | + | calledConfig atomic.Bool | |||
| 140 | + | calledKeybindings atomic.Bool | |||
| 141 | + | ||||
| 142 | + | historyEntries []*entity.HistoryEntry | |||
| 143 | + | favorites []*entity.Favorite | |||
| 144 | + | folders []*entity.Folder | |||
| 145 | + | tags []*entity.Tag | |||
| 146 | + | currentConfig dto.SystemviewConfigPayload | |||
| 147 | + | keybindings port.KeybindingsConfig | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |||
| 151 | + | f.calledHistory.Store(true) | |||
| 152 | + | return f.historyEntries, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) { | |||
| 156 | + | f.calledHistory.Store(true) | |||
| 157 | + | return f.historyEntries, nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) { | |||
| 161 | + | f.calledHistory.Store(true) | |||
| 162 | + | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |||
| 166 | + | return nil, nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil } | |||
| 170 | + | ||||
| 171 | + | func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil } | |||
| 172 | + | ||||
| 173 | + | func (*fakeBridgeService) Stats(context.Context) (*entity.HistoryStats, error) { | |||
| 174 | + | return nil, nil | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |||
| 178 | + | return nil, nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | |||
| 182 | + | return nil, nil | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil } | |||
| 186 | + | ||||
| 187 | + | func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) { | |||
| 188 | + | f.calledFavorites.Store(true) | |||
| 189 | + | return f.favorites, nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func (*fakeBridgeService) CreateFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 193 | + | return nil, nil | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (*fakeBridgeService) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 197 | + | return nil, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil } | |||
| 201 | + | ||||
| 202 | + | func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) { | |||
| 203 | + | return f.folders, nil | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) { | |||
| 207 | + | return f.tags, nil | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil } | |||
| 211 | + | ||||
| 212 | + | func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil } | |||
| 213 | + | ||||
| 214 | + | func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) { | |||
| 215 | + | return nil, nil | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil } | |||
| 219 | + | ||||
| 220 | + | func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil } | |||
| 221 | + | ||||
| 222 | + | func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) { | |||
| 223 | + | return nil, nil | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil } | |||
| 227 | + | ||||
| 228 | + | func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil } | |||
| 229 | + | ||||
| 230 | + | func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil } | |||
| 231 | + | ||||
| 232 | + | func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil } | |||
| 233 | + | ||||
| 234 | + | func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 235 | + | f.calledConfig.Store(true) | |||
| 236 | + | return f.currentConfig, nil | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 240 | + | return dto.SystemviewConfigPayload{}, nil | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil } | |||
| 244 | + | ||||
| 245 | + | func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) { | |||
| 246 | + | f.calledKeybindings.Store(true) | |||
| 247 | + | return f.keybindings, nil | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (port.SetKeybindingResponse, error) { | |||
| 251 | + | return port.SetKeybindingResponse{}, nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error { | |||
| 255 | + | return nil | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil } | |||
A cmd/systemviews/main.go +30 -0
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "syscall/js" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge" | |||
| 9 | + | "github.com/bnema/dumber/internal/ui/systemviews" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func main() { | |||
| 13 | + | bridge := systemviewsbridge.NewBrowserClient() | |||
| 14 | + | href := "" | |||
| 15 | + | location := js.Global().Get("location") | |||
| 16 | + | if location.Truthy() { | |||
| 17 | + | hrefValue := location.Get("href") | |||
| 18 | + | if hrefValue.Truthy() && hrefValue.Type() == js.TypeString { | |||
| 19 | + | href = hrefValue.String() | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | app := newBridgeApp(systemviews.NewDOM(), href, bridge) | |||
| 23 | + | ||||
| 24 | + | if err := app.Run(); err != nil { | |||
| 25 | + | js.Global().Get("console").Call("error", err.Error()) | |||
| 26 | + | panic(err) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | select {} | |||
| 30 | + | } | |||
A cmd/systemviews/main_stub.go +5 -0
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | func main() {} | |||
M docs/config/index.md +1 -1
| @@ -204,7 +204,7 @@ border = "#363636" | |||||
| 204 | **Example:** | 204 | **Example:** | ||
| 205 | ```toml | 205 | ```toml | ||
| 206 | [workspace] | 206 | [workspace] | ||
| 207 | - | new_pane_url = "dumb://home" | 207 | + | new_pane_url = "dumb://history" |
| 208 | ``` | 208 | ``` | ||
| 209 | 209 | ||||
| 210 | ### Pane Mode | 210 | ### Pane Mode | ||
M docs/reference/keybindings.md +15 -2
| @@ -80,6 +80,9 @@ These work outside modal modes: | |||||
| 80 | | Action | Keys | | 80 | | Action | Keys | | ||
| 81 | |--------|------| | 81 | |--------|------| | ||
| 82 | | Toggle floating pane | `Alt+F` | | 82 | | Toggle floating pane | `Alt+F` | | ||
| 83 | + | | Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` | | |||
| 84 | + | | Toggle Favorites system view in right split | unbound by default | | |||
| 85 | + | | Toggle Config system view in right split | unbound by default | | |||
| 83 | | Close pane (or release floating pane) | `Ctrl+W` | | 86 | | Close pane (or release floating pane) | `Ctrl+W` | | ||
| 84 | | Next tab | `Ctrl+Tab` | | 87 | | Next tab | `Ctrl+Tab` | | ||
| 85 | | Previous tab | `Ctrl+Shift+Tab` | | 88 | | Previous tab | `Ctrl+Shift+Tab` | | ||
| @@ -90,6 +93,7 @@ These work outside modal modes: | |||||
| 90 | 93 | ||||
| 91 | - `Alt+F` is the only floating-pane shortcut enabled by default. | 94 | - `Alt+F` is the only floating-pane shortcut enabled by default. | ||
| 92 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | 95 | - `Alt+F` toggles floating visibility and keeps floating pane state intact. | ||
| 96 | + | - `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary. | |||
| 93 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | 97 | - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session. | ||
| 94 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | 98 | - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`. | ||
| 95 | - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`). | 99 | - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`). | ||
| @@ -108,12 +112,21 @@ All keybindings can be customized in `~/.config/dumber/config.toml`: | |||||
| 108 | split-right = ["arrowright", "r"] | 112 | split-right = ["arrowright", "r"] | ||
| 109 | close-pane = ["x", "q"] | 113 | close-pane = ["x", "q"] | ||
| 110 | 114 | ||||
| 111 | - | [workspace.shortcuts.actions.close_pane] | 115 | + | [workspace.shortcuts.actions.close-pane] |
| 112 | keys = ["ctrl+w"] | 116 | keys = ["ctrl+w"] | ||
| 113 | 117 | ||||
| 114 | - | [workspace.shortcuts.actions.toggle_floating_pane] | 118 | + | [workspace.shortcuts.actions.toggle-floating-pane] |
| 115 | keys = ["alt+f"] | 119 | keys = ["alt+f"] | ||
| 116 | 120 | ||||
| 121 | + | [workspace.shortcuts.actions.toggle-history-systemview] | |||
| 122 | + | keys = ["ctrl+h"] | |||
| 123 | + | ||||
| 124 | + | [workspace.shortcuts.actions.toggle-favorites-systemview] | |||
| 125 | + | keys = [] | |||
| 126 | + | ||||
| 127 | + | [workspace.shortcuts.actions.toggle-config-systemview] | |||
| 128 | + | keys = [] | |||
| 129 | + | ||||
| 117 | [workspace.floating_pane] | 130 | [workspace.floating_pane] | ||
| 118 | width_pct = 0.82 | 131 | width_pct = 0.82 | ||
| 119 | height_pct = 0.72 | 132 | height_pct = 0.72 | ||
A docs/systemviews-wasm-crud-plan.md +115 -0
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | # Systemviews WASM CRUD and Architecture Plan | |||
| 2 | + | ||||
| 3 | + | This note captures the parity target for the `feat/systemviews-wasm` branch after merging `main`. | |||
| 4 | + | ||||
| 5 | + | ## Current branch baseline | |||
| 6 | + | ||||
| 7 | + | - Worktree: `.worktrees/feat/systemviews-wasm` | |||
| 8 | + | - Branch: `feat/systemviews-wasm` | |||
| 9 | + | - Merge baseline: `d84415e5` (`Merge branch 'main' into feat/systemviews-wasm`) | |||
| 10 | + | - Vendor baseline: `go mod vendor` has been run in the worktree and `go list -mod=vendor ./...` succeeds there. | |||
| 11 | + | - Root-checkout vendoring diagnostics were corrected separately with `go mod vendor`; branch validation should still use `-mod=vendor` after dependency changes. | |||
| 12 | + | ||||
| 13 | + | ## Clean architecture contract | |||
| 14 | + | ||||
| 15 | + | Keep the dependency direction explicit: | |||
| 16 | + | ||||
| 17 | + | | Layer | Role for systemviews | Must not do | | |||
| 18 | + | | --- | --- | --- | | |||
| 19 | + | | `internal/domain` | Pure entities/value objects and pure helpers such as history range parsing. | Import ports, usecases, adapters, GTK/CEF/WebKit, or config packages. | | |||
| 20 | + | | `internal/application/usecase` | Own favorites/history/config/keybinding behavior and validation. | Import infrastructure adapters or UI packages. | | |||
| 21 | + | | `internal/application/port` | Define the systemview-facing contracts and DTOs. | Depend on CEF/WebKit/SQLite/config concrete types. | | |||
| 22 | + | | `internal/infrastructure/*` | Implement handlers, scheme serving, bridge transports, persistence, config payload readers. | Put UI workflow or business rules here. | | |||
| 23 | + | | `internal/ui/systemviews` | Render and orchestrate WASM UI through ports. | Call repositories, config package, CEF/WebKit, or SQLite directly. | | |||
| 24 | + | | `internal/ui/dispatcher` / coordinators | Convert native actions into pane/tab/navigation orchestration. | Bypass usecases for domain state changes. | | |||
| 25 | + | ||||
| 26 | + | Preferred direction for new work: | |||
| 27 | + | ||||
| 28 | + | 1. Add behavior to existing usecases first when it is business logic. | |||
| 29 | + | 2. Expose it through application ports. | |||
| 30 | + | 3. Implement WebUI/systemview handlers in `internal/infrastructure/handlers`. | |||
| 31 | + | 4. Consume those handlers from `internal/infrastructure/systemviewsbridge`. | |||
| 32 | + | 5. Render and bind user interactions in `internal/ui/systemviews`. | |||
| 33 | + | ||||
| 34 | + | ## Old Svelte UI CRUD matrix | |||
| 35 | + | ||||
| 36 | + | ### History (`webui/src/pages/homepage/*`) | |||
| 37 | + | ||||
| 38 | + | | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap | | |||
| 39 | + | | --- | --- | --- | --- | --- | | |||
| 40 | + | | Read timeline | `HistoryPanel.svelte`, `HistoryTimeline.svelte`, `HistoryItem.svelte` | `history_timeline` | Present, but bare list only (`internal/ui/systemviews/history_view.go`). | Add pagination, loading/error/empty states, row metadata, favicons, visit counts. | | |||
| 41 | + | | Read/search | `HistorySearch.svelte`, `keyboard.ts` local fuzzy + FTS | `history_search_fts` | Bridge client supports it. | Add search UI, debounce/local filtering, FTS fallback, visible result count. | | |||
| 42 | + | | Read filters/stats | `HistoryFilters.svelte`, `AnalyticsPanel.svelte` | `history_domain_stats`, `history_analytics` | Bridge client supports both; config route renders keybinding/config summaries only. | Add domain chips, stats summary, analytics cards/charts or text equivalent. | | |||
| 43 | + | | Create | Browser navigation writes history outside the systemview UI. | Existing history repository/usecases. | Not a user-facing CRUD action. | No manual create needed. | | |||
| 44 | + | | Update | Not supported in old UI. | None. | None. | Keep out of scope unless a future title-edit requirement appears. | | |||
| 45 | + | | Delete one | `HistoryItem.svelte`, `keyboard.ts` | `history_delete_entry` | Bridge client supports it; UI not interactive. | Add row delete action with confirmation and refresh. | | |||
| 46 | + | | Delete range/all | `HistoryCleanup.svelte`, `keyboard.ts` | `history_delete_range`, `history_clear_all` | Bridge client supports range; route UI not interactive. | Add cleanup modal/actions for hour/day/week/month/all. | | |||
| 47 | + | | Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. | | |||
| 48 | + | | Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. | | |||
| 49 | + | ||||
| 50 | + | ### Favorites, folders, tags (`webui/src/pages/homepage/favorites/*`) | |||
| 51 | + | ||||
| 52 | + | | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap | | |||
| 53 | + | | --- | --- | --- | --- | --- | | |||
| 54 | + | | Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`). | Add rich rows/cards, favicons, shortcut badges, folder/tag metadata. | | |||
| 55 | + | | Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. | | |||
| 56 | + | | Update favorite | `FavoriteEditor.svelte` locally updates title and uses folder/shortcut/tag handlers. | `favorite_set_shortcut`, `favorite_set_folder`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep folder/shortcut/tag specialized calls or fold into update after design. | | |||
| 57 | + | | Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. | | |||
| 58 | + | | Read folders | `FolderList.svelte` | `folder_list` | Present as static list. | Add counts, selected filter, empty state. | | |||
| 59 | + | | Create folder | `FolderList.svelte` inline create | `folder_create` | Bridge and handler exist. | Add interactive UI. | | |||
| 60 | + | | Update folder | Handler exists (`folder_update`). | `folder_update` | Bridge exists. | Add rename/icon UI. | | |||
| 61 | + | | Delete folder | Handler exists (`folder_delete`). | `folder_delete` | Bridge exists. | Add UI, confirm, refresh favorite folder state. | | |||
| 62 | + | | Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. | | |||
| 63 | + | | Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. | | |||
| 64 | + | | Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. | | |||
| 65 | + | | Delete tag | Handler exists (`tag_delete`). | `tag_delete` | Bridge exists. | Add confirm UI and refresh assignments. | | |||
| 66 | + | | Assign/remove tag | `FavoriteEditor.svelte` | `tag_assign`, `tag_remove` | Bridge exists. | Add editor UI. | | |||
| 67 | + | ||||
| 68 | + | ### Config and keybindings (`webui/src/pages/ConfigPage.svelte`, `webui/src/pages/config/*`) | |||
| 69 | + | ||||
| 70 | + | | Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap | | |||
| 71 | + | | --- | --- | --- | --- | --- | | |||
| 72 | + | | Read config | `ConfigPage.svelte` | `/api/config` | Bridge supports direct API and route renders flattened values. | Replace flattened read-only view with grouped editable forms. | | |||
| 73 | + | | Read defaults | Reset dialog in `ConfigPage.svelte` | `/api/config/default` | Bridge supports direct API. | Add reset-to-default flow. | | |||
| 74 | + | | Save config | `save_config` via native bridge | `save_config` | Bridge supports it. Existing app config watcher hot-reloads appearance/keybindings/engine settings. | Add form save flow, validation, success/error UI, refresh after save. | | |||
| 75 | + | | Appearance update | Fonts, UI scale, color scheme, palettes | `save_config` | Payload shape exists in `SystemviewConfigPayload`. | Add form controls and palette validation. | | |||
| 76 | + | | Search shortcuts CRUD | `ShortcutsTable.svelte` | Saved inside config payload | Missing interactive UI. | Add table/list editing and `%s`/`{query}` validation. | | |||
| 77 | + | | Performance update | Performance tab | `save_config` | Payload shape exists; CEF gating needed. | Add profile/custom fields and restart warning. | | |||
| 78 | + | | Keybindings read | `KeybindingsTab.svelte` | `get_keybindings` | Route renders read-only keybinding groups. | Add editable keybinding UI. | | |||
| 79 | + | | Keybindings update | `KeyCaptureModal.svelte` | `set_keybinding`, `reset_keybinding`, `reset_all_keybindings` | Bridge supports message types. | Add capture/reset UI and conflict display. | | |||
| 80 | + | ||||
| 81 | + | ## Native shortcut and split-pane target | |||
| 82 | + | ||||
| 83 | + | Add first-class global actions: | |||
| 84 | + | ||||
| 85 | + | | Action | Default key | Target behavior | | |||
| 86 | + | | --- | --- | --- | | |||
| 87 | + | | `toggle_history_systemview` | `ctrl+h` | Split active pane to the right with `dumb://history`, focus an existing history systemview pane, or close it if already active. | | |||
| 88 | + | | `toggle_favorites_systemview` | Unset by default. | Split/focus/toggle `dumb://favorites`. | | |||
| 89 | + | | `toggle_config_systemview` | Unset by default; user can bind in config. | Split/focus/toggle `dumb://config`. | | |||
| 90 | + | ||||
| 91 | + | Implementation path: | |||
| 92 | + | ||||
| 93 | + | 1. Add input actions in `internal/ui/input/shortcuts.go` and config defaults/schema in `internal/infrastructure/config`. | |||
| 94 | + | 2. Map the actions in global shortcuts and keybinding DTOs. | |||
| 95 | + | 3. Add a coordinator method such as `WorkspaceCoordinator.SplitURL(ctx, SplitRight, "dumb://history")` so dispatcher does not manipulate pane internals. | |||
| 96 | + | 4. Use existing `ManagePanesUseCase.Split` with `SplitPaneInput.InitialURL` to keep domain/usecase clean. | |||
| 97 | + | 5. Add tests in input, dispatcher, config defaults/schema, and workspace coordinator. | |||
| 98 | + | ||||
| 99 | + | ## Completed branch notes | |||
| 100 | + | ||||
| 101 | + | - Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates. | |||
| 102 | + | - `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime. | |||
| 103 | + | - History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation. | |||
| 104 | + | - Favorites supports create/update/delete for favorites, folders, and tags; folder/tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation. | |||
| 105 | + | - Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save. | |||
| 106 | + | - Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path. | |||
| 107 | + | - Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states. | |||
| 108 | + | ||||
| 109 | + | ## Implementation notes | |||
| 110 | + | ||||
| 111 | + | - Do not resurrect `webui/`; the branch intentionally removed Svelte assets. | |||
| 112 | + | - Keep WASM UI code small and stateful. Avoid pushing business rules into HTML string helpers. | |||
| 113 | + | - Prefer explicit bridge request methods over ad-hoc message strings in UI code. | |||
| 114 | + | - Every destructive action needs confirmation and a visible refresh/error path. | |||
| 115 | + | - Every feature should remain usable by keyboard because these pages are browser management surfaces, not marketing pages. | |||
M go.mod +13 -1
| @@ -3,8 +3,9 @@ module github.com/bnema/dumber | |||||
| 3 | go 1.26.1 | 3 | go 1.26.1 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | + | github.com/a-h/templ v0.3.1001 | |||
| 6 | github.com/bnema/purego v0.11.0-bnema.2 | 7 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 7 | - | github.com/bnema/purego-cef v0.10.2 | 8 | + | github.com/bnema/purego-cef v0.11.0 |
| 8 | github.com/bnema/purego-ffmpeg v0.3.1 | 9 | github.com/bnema/purego-ffmpeg v0.3.1 | ||
| 9 | github.com/bnema/purego-pipewire v0.1.2 | 10 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 10 | github.com/bnema/purego-sqlite v0.1.2 | 11 | github.com/bnema/purego-sqlite v0.1.2 | ||
| @@ -27,18 +28,23 @@ require ( | |||||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | require ( | 30 | require ( | ||
| 31 | + | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect | |||
| 32 | + | github.com/andybalholm/brotli v1.2.0 // indirect | |||
| 30 | github.com/atotto/clipboard v0.1.4 // indirect | 33 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 31 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 34 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 35 | + | github.com/cenkalti/backoff/v4 v4.3.0 // indirect | |||
| 32 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | 36 | github.com/charmbracelet/colorprofile v0.4.1 // indirect | ||
| 33 | github.com/charmbracelet/x/ansi v0.11.6 // indirect | 37 | github.com/charmbracelet/x/ansi v0.11.6 // indirect | ||
| 34 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | 38 | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect | ||
| 35 | github.com/charmbracelet/x/term v0.2.2 // indirect | 39 | github.com/charmbracelet/x/term v0.2.2 // indirect | ||
| 40 | + | github.com/cli/browser v1.3.0 // indirect | |||
| 36 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | 41 | github.com/clipperhouse/displaywidth v0.9.0 // indirect | ||
| 37 | github.com/clipperhouse/stringish v0.1.1 // indirect | 42 | github.com/clipperhouse/stringish v0.1.1 // indirect | ||
| 38 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | 43 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | ||
| 39 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | 44 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | ||
| 40 | github.com/davecgh/go-spew v1.1.1 // indirect | 45 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 41 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 46 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 47 | + | github.com/fatih/color v1.16.0 // indirect | |||
| 42 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | 48 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | ||
| 43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 49 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 44 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | 50 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | ||
| @@ -50,6 +56,7 @@ require ( | |||||
| 50 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 56 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 51 | github.com/muesli/cancelreader v0.2.2 // indirect | 57 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 52 | github.com/muesli/termenv v0.16.0 // indirect | 58 | github.com/muesli/termenv v0.16.0 // indirect | ||
| 59 | + | github.com/natefinch/atomic v1.0.1 // indirect | |||
| 53 | github.com/pmezard/go-difflib v1.0.0 // indirect | 60 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| 54 | github.com/rivo/uniseg v0.4.7 // indirect | 61 | github.com/rivo/uniseg v0.4.7 // indirect | ||
| 55 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | 62 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||
| @@ -64,8 +71,13 @@ require ( | |||||
| 64 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | 71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect | ||
| 65 | go.uber.org/multierr v1.11.0 // indirect | 72 | go.uber.org/multierr v1.11.0 // indirect | ||
| 66 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 73 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 74 | + | golang.org/x/mod v0.34.0 // indirect | |||
| 75 | + | golang.org/x/net v0.52.0 // indirect | |||
| 67 | golang.org/x/sync v0.20.0 // indirect | 76 | golang.org/x/sync v0.20.0 // indirect | ||
| 68 | golang.org/x/text v0.36.0 // indirect | 77 | golang.org/x/text v0.36.0 // indirect | ||
| 78 | + | golang.org/x/tools v0.43.0 // indirect | |||
| 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | 79 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | ||
| 70 | modernc.org/sqlite v1.47.0 // indirect | 80 | modernc.org/sqlite v1.47.0 // indirect | ||
| 71 | ) | 81 | ) | ||
| 82 | + | ||||
| 83 | + | tool github.com/a-h/templ/cmd/templ | |||
M go.sum +24 -0 generated file: collapsed
A internal/application/dto/favorites.go +21 -0
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 5 | + | // FavoriteCreateInput contains fields needed to create a favorite from a UI port. | |||
| 6 | + | type FavoriteCreateInput struct { | |||
| 7 | + | URL string `json:"url"` | |||
| 8 | + | Title string `json:"title"` | |||
| 9 | + | FaviconURL string `json:"favicon_url"` | |||
| 10 | + | FolderID *entity.FolderID `json:"folder_id"` | |||
| 11 | + | Tags []entity.TagID `json:"tags"` | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // FavoriteUpdateInput contains editable favorite metadata. | |||
| 15 | + | type FavoriteUpdateInput struct { | |||
| 16 | + | ID entity.FavoriteID `json:"id"` | |||
| 17 | + | Title string `json:"title"` | |||
| 18 | + | FaviconURL string `json:"favicon_url"` | |||
| 19 | + | FolderID *entity.FolderID `json:"folder_id"` | |||
| 20 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 21 | + | } | |||
A internal/application/dto/history.go +14 -0
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |||
| 4 | + | ||||
| 5 | + | // HistorySearchInput holds search parameters for history search use cases. | |||
| 6 | + | type HistorySearchInput struct { | |||
| 7 | + | Query string | |||
| 8 | + | Limit int | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | // HistorySearchOutput holds search results for history search use cases. | |||
| 12 | + | type HistorySearchOutput struct { | |||
| 13 | + | Matches []entity.HistoryMatch | |||
| 14 | + | } | |||
A internal/application/dto/webui_config.go +92 -0
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | // WebUIConfig represents the subset of configuration editable in dumb://config. | |||
| 4 | + | type WebUIConfig struct { | |||
| 5 | + | Appearance WebUIAppearanceConfig `json:"appearance"` | |||
| 6 | + | Performance WebUIPerformanceConfig `json:"performance"` | |||
| 7 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 8 | + | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 9 | + | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // SystemviewCustomPerformancePayload is the editable custom performance profile shape. | |||
| 13 | + | type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig | |||
| 14 | + | ||||
| 15 | + | // SystemviewResolvedPerformancePayload is the resolved effective performance shape. | |||
| 16 | + | type SystemviewResolvedPerformancePayload struct { | |||
| 17 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 18 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 19 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 20 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 21 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 22 | + | ConservativeThreshold float64 `json:"conservative_threshold"` | |||
| 23 | + | StrictThreshold float64 `json:"strict_threshold"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // SystemviewHardwarePayload exposes detected hardware to the settings UI. | |||
| 27 | + | type SystemviewHardwarePayload struct { | |||
| 28 | + | CPUCores int `json:"cpu_cores"` | |||
| 29 | + | CPUThreads int `json:"cpu_threads"` | |||
| 30 | + | TotalRAMMB int `json:"total_ram_mb"` | |||
| 31 | + | GPUVendor string `json:"gpu_vendor"` | |||
| 32 | + | GPUName string `json:"gpu_name"` | |||
| 33 | + | VRAMMB int `json:"vram_mb"` | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // SystemviewPerformancePayload is the performance tab payload expected by the UI. | |||
| 37 | + | type SystemviewPerformancePayload struct { | |||
| 38 | + | Profile string `json:"profile"` | |||
| 39 | + | Custom SystemviewCustomPerformancePayload `json:"custom"` | |||
| 40 | + | Resolved SystemviewResolvedPerformancePayload `json:"resolved"` | |||
| 41 | + | Hardware SystemviewHardwarePayload `json:"hardware"` | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // SystemviewConfigPayload is the full JSON shape expected by dumb://config. | |||
| 45 | + | type SystemviewConfigPayload struct { | |||
| 46 | + | Appearance WebUIAppearanceConfig `json:"appearance"` | |||
| 47 | + | Performance SystemviewPerformancePayload `json:"performance"` | |||
| 48 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 49 | + | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 50 | + | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | |||
| 51 | + | EngineType string `json:"engine_type"` | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // WebUIPerformanceConfig represents performance settings editable in dumb://config. | |||
| 55 | + | type WebUIPerformanceConfig struct { | |||
| 56 | + | Profile string `json:"profile"` | |||
| 57 | + | Custom WebUICustomPerformanceConfig `json:"custom"` | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // WebUICustomPerformanceConfig holds user-editable fields for custom profile. | |||
| 61 | + | type WebUICustomPerformanceConfig struct { | |||
| 62 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 63 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 64 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 65 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 66 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | type WebUIAppearanceConfig struct { | |||
| 70 | + | SansFont string `json:"sans_font"` | |||
| 71 | + | SerifFont string `json:"serif_font"` | |||
| 72 | + | MonospaceFont string `json:"monospace_font"` | |||
| 73 | + | DefaultFontSize int `json:"default_font_size"` | |||
| 74 | + | ColorScheme string `json:"color_scheme"` | |||
| 75 | + | LightPalette ColorPalette `json:"light_palette"` | |||
| 76 | + | DarkPalette ColorPalette `json:"dark_palette"` | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | type ColorPalette struct { | |||
| 80 | + | Background string `json:"background"` | |||
| 81 | + | Surface string `json:"surface"` | |||
| 82 | + | SurfaceVariant string `json:"surface_variant"` | |||
| 83 | + | Text string `json:"text"` | |||
| 84 | + | Muted string `json:"muted"` | |||
| 85 | + | Accent string `json:"accent"` | |||
| 86 | + | Border string `json:"border"` | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | type SearchShortcut struct { | |||
| 90 | + | URL string `json:"url"` | |||
| 91 | + | Description string `json:"description"` | |||
| 92 | + | } | |||
M internal/application/port/favicon_service.go +7 -0
| @@ -9,6 +9,7 @@ type FaviconService interface { | |||||
| 9 | DiskPathPNG(domain string) string | 9 | DiskPathPNG(domain string) string | ||
| 10 | HasPNGOnDisk(domain string) bool | 10 | HasPNGOnDisk(domain string) bool | ||
| 11 | HasPNGSizedOnDisk(domain string, size int) bool | 11 | HasPNGSizedOnDisk(domain string, size int) bool | ||
| 12 | + | DiskPathPNGSized(domain string, size int) string | |||
| 12 | EnsureSizedPNG(ctx context.Context, domain string, size int) error | 13 | EnsureSizedPNG(ctx context.Context, domain string, size int) error | ||
| 13 | EnsureCacheDir() error | 14 | EnsureCacheDir() error | ||
| 14 | // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given | 15 | // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given | ||
| @@ -17,3 +18,9 @@ type FaviconService interface { | |||||
| 17 | EnsureDiskCache(ctx context.Context, domain string) | 18 | EnsureDiskCache(ctx context.Context, domain string) | ||
| 18 | Close() | 19 | Close() | ||
| 19 | } | 20 | } | ||
| 21 | + | ||||
| 22 | + | // SystemviewFaviconServiceSetter is an optional engine capability that lets the | |||
| 23 | + | // UI expose cached favicons to internal systemview pages without remote image loads. | |||
| 24 | + | type SystemviewFaviconServiceSetter interface { | |||
| 25 | + | SetSystemviewFaviconService(service FaviconService) | |||
| 26 | + | } | |||
M internal/application/port/handler_deps.go +2 -1
| @@ -3,6 +3,7 @@ package port | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 6 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| @@ -43,7 +44,7 @@ type AutoCopyConfig interface { | |||||
| 43 | // A struct is used here to avoid reverse dependencies between the bootstrap and | 44 | // A struct is used here to avoid reverse dependencies between the bootstrap and | ||
| 44 | // UI layers, which both need to reference this shared dependency container. | 45 | // UI layers, which both need to reference this shared dependency container. | ||
| 45 | type HandlerDeps struct { | 46 | type HandlerDeps struct { | ||
| 46 | - | SaveConfig func(context.Context, WebUIConfig) error | 47 | + | SaveConfig func(context.Context, dto.WebUIConfig) error |
| 47 | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error | 48 | SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error | ||
| 48 | KeybindingsGetter KeybindingsGetter | 49 | KeybindingsGetter KeybindingsGetter | ||
| 49 | KeybindingSetter KeybindingSetter | 50 | KeybindingSetter KeybindingSetter | ||
M internal/application/port/homepage.go +10 -13
| @@ -4,27 +4,21 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | - | // HistorySearchInput holds search parameters (moved from usecase.SearchInput). | |||
| 11 | - | type HistorySearchInput struct { | |||
| 12 | - | Query string | |||
| 13 | - | Limit int | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | // HistorySearchOutput holds search results (moved from usecase.SearchOutput). | |||
| 17 | - | type HistorySearchOutput struct { | |||
| 18 | - | Matches []entity.HistoryMatch | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | // HomepageHistory provides history operations needed by the WebUI homepage handlers. | 11 | // HomepageHistory provides history operations needed by the WebUI homepage handlers. | ||
| 22 | type HomepageHistory interface { | 12 | type HomepageHistory interface { | ||
| 23 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 13 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 24 | - | Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error) | 14 | + | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) |
| 15 | + | GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |||
| 16 | + | Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) | |||
| 25 | Delete(ctx context.Context, id int64) error | 17 | Delete(ctx context.Context, id int64) error | ||
| 18 | + | ClearRange(ctx context.Context, rangeID string) error | |||
| 26 | ClearAll(ctx context.Context) error | 19 | ClearAll(ctx context.Context) error | ||
| 27 | ClearOlderThan(ctx context.Context, before time.Time) error | 20 | ClearOlderThan(ctx context.Context, before time.Time) error | ||
| 21 | + | GetStats(ctx context.Context) (*entity.HistoryStats, error) | |||
| 28 | GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) | 22 | GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) | ||
| 29 | GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | 23 | GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | ||
| 30 | DeleteByDomain(ctx context.Context, domain string) error | 24 | DeleteByDomain(ctx context.Context, domain string) error | ||
| @@ -34,11 +28,14 @@ type HomepageHistory interface { | |||||
| 34 | // needed by the WebUI homepage handlers. | 28 | // needed by the WebUI homepage handlers. | ||
| 35 | type HomepageFavorites interface { | 29 | type HomepageFavorites interface { | ||
| 36 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | 30 | GetAll(ctx context.Context) ([]*entity.Favorite, error) | ||
| 31 | + | AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | |||
| 32 | + | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | |||
| 33 | + | DeleteFavorite(ctx context.Context, id entity.FavoriteID) error | |||
| 37 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | 34 | SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error | ||
| 38 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | 35 | GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) | ||
| 39 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | 36 | Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error | ||
| 40 | GetAllFolders(ctx context.Context) ([]*entity.Folder, error) | 37 | GetAllFolders(ctx context.Context) ([]*entity.Folder, error) | ||
| 41 | - | CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) | 38 | + | CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) |
| 42 | DeleteFolder(ctx context.Context, id entity.FolderID) error | 39 | DeleteFolder(ctx context.Context, id entity.FolderID) error | ||
| 43 | UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error | 40 | UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error | ||
| 44 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | 41 | GetAllTags(ctx context.Context) ([]*entity.Tag, error) | ||
M internal/application/port/mocks/mocks.go +709 -33
| @@ -5052,6 +5052,63 @@ func (_c *MockFaviconService_DiskPathPNG_Call) RunAndReturn(run func(domain stri | |||||
| 5052 | return _c | 5052 | return _c | ||
| 5053 | } | 5053 | } | ||
| 5054 | 5054 | ||||
| 5055 | + | // DiskPathPNGSized provides a mock function for the type MockFaviconService | |||
| 5056 | + | func (_mock *MockFaviconService) DiskPathPNGSized(domain string, size int) string { | |||
| 5057 | + | ret := _mock.Called(domain, size) | |||
| 5058 | + | ||||
| 5059 | + | if len(ret) == 0 { | |||
| 5060 | + | panic("no return value specified for DiskPathPNGSized") | |||
| 5061 | + | } | |||
| 5062 | + | ||||
| 5063 | + | var r0 string | |||
| 5064 | + | if returnFunc, ok := ret.Get(0).(func(string, int) string); ok { | |||
| 5065 | + | r0 = returnFunc(domain, size) | |||
| 5066 | + | } else { | |||
| 5067 | + | r0 = ret.Get(0).(string) | |||
| 5068 | + | } | |||
| 5069 | + | return r0 | |||
| 5070 | + | } | |||
| 5071 | + | ||||
| 5072 | + | // MockFaviconService_DiskPathPNGSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiskPathPNGSized' | |||
| 5073 | + | type MockFaviconService_DiskPathPNGSized_Call struct { | |||
| 5074 | + | *mock.Call | |||
| 5075 | + | } | |||
| 5076 | + | ||||
| 5077 | + | // DiskPathPNGSized is a helper method to define mock.On call | |||
| 5078 | + | // - domain string | |||
| 5079 | + | // - size int | |||
| 5080 | + | func (_e *MockFaviconService_Expecter) DiskPathPNGSized(domain interface{}, size interface{}) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5081 | + | return &MockFaviconService_DiskPathPNGSized_Call{Call: _e.mock.On("DiskPathPNGSized", domain, size)} | |||
| 5082 | + | } | |||
| 5083 | + | ||||
| 5084 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) Run(run func(domain string, size int)) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5085 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 5086 | + | var arg0 string | |||
| 5087 | + | if args[0] != nil { | |||
| 5088 | + | arg0 = args[0].(string) | |||
| 5089 | + | } | |||
| 5090 | + | var arg1 int | |||
| 5091 | + | if args[1] != nil { | |||
| 5092 | + | arg1 = args[1].(int) | |||
| 5093 | + | } | |||
| 5094 | + | run( | |||
| 5095 | + | arg0, | |||
| 5096 | + | arg1, | |||
| 5097 | + | ) | |||
| 5098 | + | }) | |||
| 5099 | + | return _c | |||
| 5100 | + | } | |||
| 5101 | + | ||||
| 5102 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) Return(s string) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5103 | + | _c.Call.Return(s) | |||
| 5104 | + | return _c | |||
| 5105 | + | } | |||
| 5106 | + | ||||
| 5107 | + | func (_c *MockFaviconService_DiskPathPNGSized_Call) RunAndReturn(run func(domain string, size int) string) *MockFaviconService_DiskPathPNGSized_Call { | |||
| 5108 | + | _c.Call.Return(run) | |||
| 5109 | + | return _c | |||
| 5110 | + | } | |||
| 5111 | + | ||||
| 5055 | // EnsureCacheDir provides a mock function for the type MockFaviconService | 5112 | // EnsureCacheDir provides a mock function for the type MockFaviconService | ||
| 5056 | func (_mock *MockFaviconService) EnsureCacheDir() error { | 5113 | func (_mock *MockFaviconService) EnsureCacheDir() error { | ||
| 5057 | ret := _mock.Called() | 5114 | ret := _mock.Called() | ||
| @@ -6547,6 +6604,63 @@ func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx con | |||||
| 6547 | return _c | 6604 | return _c | ||
| 6548 | } | 6605 | } | ||
| 6549 | 6606 | ||||
| 6607 | + | // ClearRange provides a mock function for the type MockHomepageHistory | |||
| 6608 | + | func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error { | |||
| 6609 | + | ret := _mock.Called(ctx, rangeID) | |||
| 6610 | + | ||||
| 6611 | + | if len(ret) == 0 { | |||
| 6612 | + | panic("no return value specified for ClearRange") | |||
| 6613 | + | } | |||
| 6614 | + | ||||
| 6615 | + | var r0 error | |||
| 6616 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |||
| 6617 | + | r0 = returnFunc(ctx, rangeID) | |||
| 6618 | + | } else { | |||
| 6619 | + | r0 = ret.Error(0) | |||
| 6620 | + | } | |||
| 6621 | + | return r0 | |||
| 6622 | + | } | |||
| 6623 | + | ||||
| 6624 | + | // MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange' | |||
| 6625 | + | type MockHomepageHistory_ClearRange_Call struct { | |||
| 6626 | + | *mock.Call | |||
| 6627 | + | } | |||
| 6628 | + | ||||
| 6629 | + | // ClearRange is a helper method to define mock.On call | |||
| 6630 | + | // - ctx context.Context | |||
| 6631 | + | // - rangeID string | |||
| 6632 | + | func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call { | |||
| 6633 | + | return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)} | |||
| 6634 | + | } | |||
| 6635 | + | ||||
| 6636 | + | func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call { | |||
| 6637 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 6638 | + | var arg0 context.Context | |||
| 6639 | + | if args[0] != nil { | |||
| 6640 | + | arg0 = args[0].(context.Context) | |||
| 6641 | + | } | |||
| 6642 | + | var arg1 string | |||
| 6643 | + | if args[1] != nil { | |||
| 6644 | + | arg1 = args[1].(string) | |||
| 6645 | + | } | |||
| 6646 | + | run( | |||
| 6647 | + | arg0, | |||
| 6648 | + | arg1, | |||
| 6649 | + | ) | |||
| 6650 | + | }) | |||
| 6651 | + | return _c | |||
| 6652 | + | } | |||
| 6653 | + | ||||
| 6654 | + | func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call { | |||
| 6655 | + | _c.Call.Return(err) | |||
| 6656 | + | return _c | |||
| 6657 | + | } | |||
| 6658 | + | ||||
| 6659 | + | func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call { | |||
| 6660 | + | _c.Call.Return(run) | |||
| 6661 | + | return _c | |||
| 6662 | + | } | |||
| 6663 | + | ||||
| 6550 | // Delete provides a mock function for the type MockHomepageHistory | 6664 | // Delete provides a mock function for the type MockHomepageHistory | ||
| 6551 | func (_mock *MockHomepageHistory) Delete(ctx context.Context, id int64) error { | 6665 | func (_mock *MockHomepageHistory) Delete(ctx context.Context, id int64) error { | ||
| 6552 | ret := _mock.Called(ctx, id) | 6666 | ret := _mock.Called(ctx, id) | ||
| @@ -6865,27 +6979,243 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context. | |||||
| 6865 | return _c | 6979 | return _c | ||
| 6866 | } | 6980 | } | ||
| 6867 | 6981 | ||||
| 6982 | + | // GetRecentByDomain provides a mock function for the type MockHomepageHistory | |||
| 6983 | + | func (_mock *MockHomepageHistory) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) { | |||
| 6984 | + | ret := _mock.Called(ctx, domain, limit, offset) | |||
| 6985 | + | ||||
| 6986 | + | if len(ret) == 0 { | |||
| 6987 | + | panic("no return value specified for GetRecentByDomain") | |||
| 6988 | + | } | |||
| 6989 | + | ||||
| 6990 | + | var r0 []*entity.HistoryEntry | |||
| 6991 | + | var r1 error | |||
| 6992 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok { | |||
| 6993 | + | return returnFunc(ctx, domain, limit, offset) | |||
| 6994 | + | } | |||
| 6995 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok { | |||
| 6996 | + | r0 = returnFunc(ctx, domain, limit, offset) | |||
| 6997 | + | } else { | |||
| 6998 | + | if ret.Get(0) != nil { | |||
| 6999 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 7000 | + | } | |||
| 7001 | + | } | |||
| 7002 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok { | |||
| 7003 | + | r1 = returnFunc(ctx, domain, limit, offset) | |||
| 7004 | + | } else { | |||
| 7005 | + | r1 = ret.Error(1) | |||
| 7006 | + | } | |||
| 7007 | + | return r0, r1 | |||
| 7008 | + | } | |||
| 7009 | + | ||||
| 7010 | + | // MockHomepageHistory_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain' | |||
| 7011 | + | type MockHomepageHistory_GetRecentByDomain_Call struct { | |||
| 7012 | + | *mock.Call | |||
| 7013 | + | } | |||
| 7014 | + | ||||
| 7015 | + | // GetRecentByDomain is a helper method to define mock.On call | |||
| 7016 | + | // - ctx context.Context | |||
| 7017 | + | // - domain string | |||
| 7018 | + | // - limit int | |||
| 7019 | + | // - offset int | |||
| 7020 | + | func (_e *MockHomepageHistory_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHomepageHistory_GetRecentByDomain_Call { | |||
| 7021 | + | return &MockHomepageHistory_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)} | |||
| 7022 | + | } | |||
| 7023 | + | ||||
| 7024 | + | func (_c *MockHomepageHistory_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHomepageHistory_GetRecentByDomain_Call { | |||
| 7025 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7026 | + | var arg0 context.Context | |||
| 7027 | + | if args[0] != nil { | |||
| 7028 | + | arg0 = args[0].(context.Context) | |||
| 7029 | + | } | |||
| 7030 | + | var arg1 string | |||
| 7031 | + | if args[1] != nil { | |||
| 7032 | + | arg1 = args[1].(string) | |||
| 7033 | + | } | |||
| 7034 | + | var arg2 int | |||
| 7035 | + | if args[2] != nil { | |||
| 7036 | + | arg2 = args[2].(int) | |||
| 7037 | + | } | |||
| 7038 | + | var arg3 int | |||
| 7039 | + | if args[3] != nil { | |||
| 7040 | + | arg3 = args[3].(int) | |||
| 7041 | + | } | |||
| 7042 | + | run( | |||
| 7043 | + | arg0, | |||
| 7044 | + | arg1, | |||
| 7045 | + | arg2, | |||
| 7046 | + | arg3, | |||
| 7047 | + | ) | |||
| 7048 | + | }) | |||
| 7049 | + | return _c | |||
| 7050 | + | } | |||
| 7051 | + | ||||
| 7052 | + | func (_c *MockHomepageHistory_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHomepageHistory_GetRecentByDomain_Call { | |||
| 7053 | + | _c.Call.Return(historyEntrys, err) | |||
| 7054 | + | return _c | |||
| 7055 | + | } | |||
| 7056 | + | ||||
| 7057 | + | func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHomepageHistory_GetRecentByDomain_Call { | |||
| 7058 | + | _c.Call.Return(run) | |||
| 7059 | + | return _c | |||
| 7060 | + | } | |||
| 7061 | + | ||||
| 7062 | + | // GetRecentWindow provides a mock function for the type MockHomepageHistory | |||
| 7063 | + | func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 7064 | + | ret := _mock.Called(ctx, before, domain) | |||
| 7065 | + | ||||
| 7066 | + | if len(ret) == 0 { | |||
| 7067 | + | panic("no return value specified for GetRecentWindow") | |||
| 7068 | + | } | |||
| 7069 | + | ||||
| 7070 | + | var r0 *entity.HistoryWindow | |||
| 7071 | + | var r1 error | |||
| 7072 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok { | |||
| 7073 | + | return returnFunc(ctx, before, domain) | |||
| 7074 | + | } | |||
| 7075 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok { | |||
| 7076 | + | r0 = returnFunc(ctx, before, domain) | |||
| 7077 | + | } else { | |||
| 7078 | + | if ret.Get(0) != nil { | |||
| 7079 | + | r0 = ret.Get(0).(*entity.HistoryWindow) | |||
| 7080 | + | } | |||
| 7081 | + | } | |||
| 7082 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok { | |||
| 7083 | + | r1 = returnFunc(ctx, before, domain) | |||
| 7084 | + | } else { | |||
| 7085 | + | r1 = ret.Error(1) | |||
| 7086 | + | } | |||
| 7087 | + | return r0, r1 | |||
| 7088 | + | } | |||
| 7089 | + | ||||
| 7090 | + | // MockHomepageHistory_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow' | |||
| 7091 | + | type MockHomepageHistory_GetRecentWindow_Call struct { | |||
| 7092 | + | *mock.Call | |||
| 7093 | + | } | |||
| 7094 | + | ||||
| 7095 | + | // GetRecentWindow is a helper method to define mock.On call | |||
| 7096 | + | // - ctx context.Context | |||
| 7097 | + | // - before time.Time | |||
| 7098 | + | // - domain string | |||
| 7099 | + | func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx interface{}, before interface{}, domain interface{}) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7100 | + | return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)} | |||
| 7101 | + | } | |||
| 7102 | + | ||||
| 7103 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7104 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7105 | + | var arg0 context.Context | |||
| 7106 | + | if args[0] != nil { | |||
| 7107 | + | arg0 = args[0].(context.Context) | |||
| 7108 | + | } | |||
| 7109 | + | var arg1 time.Time | |||
| 7110 | + | if args[1] != nil { | |||
| 7111 | + | arg1 = args[1].(time.Time) | |||
| 7112 | + | } | |||
| 7113 | + | var arg2 string | |||
| 7114 | + | if args[2] != nil { | |||
| 7115 | + | arg2 = args[2].(string) | |||
| 7116 | + | } | |||
| 7117 | + | run( | |||
| 7118 | + | arg0, | |||
| 7119 | + | arg1, | |||
| 7120 | + | arg2, | |||
| 7121 | + | ) | |||
| 7122 | + | }) | |||
| 7123 | + | return _c | |||
| 7124 | + | } | |||
| 7125 | + | ||||
| 7126 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity.HistoryWindow, err error) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7127 | + | _c.Call.Return(historyWindow, err) | |||
| 7128 | + | return _c | |||
| 7129 | + | } | |||
| 7130 | + | ||||
| 7131 | + | func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call { | |||
| 7132 | + | _c.Call.Return(run) | |||
| 7133 | + | return _c | |||
| 7134 | + | } | |||
| 7135 | + | ||||
| 7136 | + | // GetStats provides a mock function for the type MockHomepageHistory | |||
| 7137 | + | func (_mock *MockHomepageHistory) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |||
| 7138 | + | ret := _mock.Called(ctx) | |||
| 7139 | + | ||||
| 7140 | + | if len(ret) == 0 { | |||
| 7141 | + | panic("no return value specified for GetStats") | |||
| 7142 | + | } | |||
| 7143 | + | ||||
| 7144 | + | var r0 *entity.HistoryStats | |||
| 7145 | + | var r1 error | |||
| 7146 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.HistoryStats, error)); ok { | |||
| 7147 | + | return returnFunc(ctx) | |||
| 7148 | + | } | |||
| 7149 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.HistoryStats); ok { | |||
| 7150 | + | r0 = returnFunc(ctx) | |||
| 7151 | + | } else { | |||
| 7152 | + | if ret.Get(0) != nil { | |||
| 7153 | + | r0 = ret.Get(0).(*entity.HistoryStats) | |||
| 7154 | + | } | |||
| 7155 | + | } | |||
| 7156 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 7157 | + | r1 = returnFunc(ctx) | |||
| 7158 | + | } else { | |||
| 7159 | + | r1 = ret.Error(1) | |||
| 7160 | + | } | |||
| 7161 | + | return r0, r1 | |||
| 7162 | + | } | |||
| 7163 | + | ||||
| 7164 | + | // MockHomepageHistory_GetStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStats' | |||
| 7165 | + | type MockHomepageHistory_GetStats_Call struct { | |||
| 7166 | + | *mock.Call | |||
| 7167 | + | } | |||
| 7168 | + | ||||
| 7169 | + | // GetStats is a helper method to define mock.On call | |||
| 7170 | + | // - ctx context.Context | |||
| 7171 | + | func (_e *MockHomepageHistory_Expecter) GetStats(ctx interface{}) *MockHomepageHistory_GetStats_Call { | |||
| 7172 | + | return &MockHomepageHistory_GetStats_Call{Call: _e.mock.On("GetStats", ctx)} | |||
| 7173 | + | } | |||
| 7174 | + | ||||
| 7175 | + | func (_c *MockHomepageHistory_GetStats_Call) Run(run func(ctx context.Context)) *MockHomepageHistory_GetStats_Call { | |||
| 7176 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7177 | + | var arg0 context.Context | |||
| 7178 | + | if args[0] != nil { | |||
| 7179 | + | arg0 = args[0].(context.Context) | |||
| 7180 | + | } | |||
| 7181 | + | run( | |||
| 7182 | + | arg0, | |||
| 7183 | + | ) | |||
| 7184 | + | }) | |||
| 7185 | + | return _c | |||
| 7186 | + | } | |||
| 7187 | + | ||||
| 7188 | + | func (_c *MockHomepageHistory_GetStats_Call) Return(historyStats *entity.HistoryStats, err error) *MockHomepageHistory_GetStats_Call { | |||
| 7189 | + | _c.Call.Return(historyStats, err) | |||
| 7190 | + | return _c | |||
| 7191 | + | } | |||
| 7192 | + | ||||
| 7193 | + | func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.Context) (*entity.HistoryStats, error)) *MockHomepageHistory_GetStats_Call { | |||
| 7194 | + | _c.Call.Return(run) | |||
| 7195 | + | return _c | |||
| 7196 | + | } | |||
| 7197 | + | ||||
| 6868 | // Search provides a mock function for the type MockHomepageHistory | 7198 | // Search provides a mock function for the type MockHomepageHistory | ||
| 6869 | - | func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) { | 7199 | + | func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) { |
| 6870 | ret := _mock.Called(ctx, input) | 7200 | ret := _mock.Called(ctx, input) | ||
| 6871 | 7201 | ||||
| 6872 | if len(ret) == 0 { | 7202 | if len(ret) == 0 { | ||
| 6873 | panic("no return value specified for Search") | 7203 | panic("no return value specified for Search") | ||
| 6874 | } | 7204 | } | ||
| 6875 | 7205 | ||||
| 6876 | - | var r0 *port.HistorySearchOutput | 7206 | + | var r0 *dto.HistorySearchOutput |
| 6877 | var r1 error | 7207 | var r1 error | ||
| 6878 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok { | 7208 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok { |
| 6879 | return returnFunc(ctx, input) | 7209 | return returnFunc(ctx, input) | ||
| 6880 | } | 7210 | } | ||
| 6881 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok { | 7211 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok { |
| 6882 | r0 = returnFunc(ctx, input) | 7212 | r0 = returnFunc(ctx, input) | ||
| 6883 | } else { | 7213 | } else { | ||
| 6884 | if ret.Get(0) != nil { | 7214 | if ret.Get(0) != nil { | ||
| 6885 | - | r0 = ret.Get(0).(*port.HistorySearchOutput) | 7215 | + | r0 = ret.Get(0).(*dto.HistorySearchOutput) |
| 6886 | } | 7216 | } | ||
| 6887 | } | 7217 | } | ||
| 6888 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok { | 7218 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok { |
| 6889 | r1 = returnFunc(ctx, input) | 7219 | r1 = returnFunc(ctx, input) | ||
| 6890 | } else { | 7220 | } else { | ||
| 6891 | r1 = ret.Error(1) | 7221 | r1 = ret.Error(1) | ||
| @@ -6900,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct { | |||||
| 6900 | 7230 | ||||
| 6901 | // Search is a helper method to define mock.On call | 7231 | // Search is a helper method to define mock.On call | ||
| 6902 | // - ctx context.Context | 7232 | // - ctx context.Context | ||
| 6903 | - | // - input port.HistorySearchInput | 7233 | + | // - input dto.HistorySearchInput |
| 6904 | func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call { | 7234 | func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call { | ||
| 6905 | return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)} | 7235 | return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)} | ||
| 6906 | } | 7236 | } | ||
| 6907 | 7237 | ||||
| 6908 | - | func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call { | 7238 | + | func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call { |
| 6909 | _c.Call.Run(func(args mock.Arguments) { | 7239 | _c.Call.Run(func(args mock.Arguments) { | ||
| 6910 | var arg0 context.Context | 7240 | var arg0 context.Context | ||
| 6911 | if args[0] != nil { | 7241 | if args[0] != nil { | ||
| 6912 | arg0 = args[0].(context.Context) | 7242 | arg0 = args[0].(context.Context) | ||
| 6913 | } | 7243 | } | ||
| 6914 | - | var arg1 port.HistorySearchInput | 7244 | + | var arg1 dto.HistorySearchInput |
| 6915 | if args[1] != nil { | 7245 | if args[1] != nil { | ||
| 6916 | - | arg1 = args[1].(port.HistorySearchInput) | 7246 | + | arg1 = args[1].(dto.HistorySearchInput) |
| 6917 | } | 7247 | } | ||
| 6918 | run( | 7248 | run( | ||
| 6919 | arg0, | 7249 | arg0, | ||
| @@ -6923,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp | |||||
| 6923 | return _c | 7253 | return _c | ||
| 6924 | } | 7254 | } | ||
| 6925 | 7255 | ||||
| 6926 | - | func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call { | 7256 | + | func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call { |
| 6927 | _c.Call.Return(historySearchOutput, err) | 7257 | _c.Call.Return(historySearchOutput, err) | ||
| 6928 | return _c | 7258 | return _c | ||
| 6929 | } | 7259 | } | ||
| 6930 | 7260 | ||||
| 6931 | - | func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call { | 7261 | + | func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call { |
| 6932 | _c.Call.Return(run) | 7262 | _c.Call.Return(run) | ||
| 6933 | return _c | 7263 | return _c | ||
| 6934 | } | 7264 | } | ||
| @@ -6960,6 +7290,74 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter { | |||||
| 6960 | return &MockHomepageFavorites_Expecter{mock: &_m.Mock} | 7290 | return &MockHomepageFavorites_Expecter{mock: &_m.Mock} | ||
| 6961 | } | 7291 | } | ||
| 6962 | 7292 | ||||
| 7293 | + | // AddFavorite provides a mock function for the type MockHomepageFavorites | |||
| 7294 | + | func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 7295 | + | ret := _mock.Called(ctx, input) | |||
| 7296 | + | ||||
| 7297 | + | if len(ret) == 0 { | |||
| 7298 | + | panic("no return value specified for AddFavorite") | |||
| 7299 | + | } | |||
| 7300 | + | ||||
| 7301 | + | var r0 *entity.Favorite | |||
| 7302 | + | var r1 error | |||
| 7303 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error)); ok { | |||
| 7304 | + | return returnFunc(ctx, input) | |||
| 7305 | + | } | |||
| 7306 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) *entity.Favorite); ok { | |||
| 7307 | + | r0 = returnFunc(ctx, input) | |||
| 7308 | + | } else { | |||
| 7309 | + | if ret.Get(0) != nil { | |||
| 7310 | + | r0 = ret.Get(0).(*entity.Favorite) | |||
| 7311 | + | } | |||
| 7312 | + | } | |||
| 7313 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteCreateInput) error); ok { | |||
| 7314 | + | r1 = returnFunc(ctx, input) | |||
| 7315 | + | } else { | |||
| 7316 | + | r1 = ret.Error(1) | |||
| 7317 | + | } | |||
| 7318 | + | return r0, r1 | |||
| 7319 | + | } | |||
| 7320 | + | ||||
| 7321 | + | // MockHomepageFavorites_AddFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFavorite' | |||
| 7322 | + | type MockHomepageFavorites_AddFavorite_Call struct { | |||
| 7323 | + | *mock.Call | |||
| 7324 | + | } | |||
| 7325 | + | ||||
| 7326 | + | // AddFavorite is a helper method to define mock.On call | |||
| 7327 | + | // - ctx context.Context | |||
| 7328 | + | // - input dto.FavoriteCreateInput | |||
| 7329 | + | func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call { | |||
| 7330 | + | return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)} | |||
| 7331 | + | } | |||
| 7332 | + | ||||
| 7333 | + | func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call { | |||
| 7334 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7335 | + | var arg0 context.Context | |||
| 7336 | + | if args[0] != nil { | |||
| 7337 | + | arg0 = args[0].(context.Context) | |||
| 7338 | + | } | |||
| 7339 | + | var arg1 dto.FavoriteCreateInput | |||
| 7340 | + | if args[1] != nil { | |||
| 7341 | + | arg1 = args[1].(dto.FavoriteCreateInput) | |||
| 7342 | + | } | |||
| 7343 | + | run( | |||
| 7344 | + | arg0, | |||
| 7345 | + | arg1, | |||
| 7346 | + | ) | |||
| 7347 | + | }) | |||
| 7348 | + | return _c | |||
| 7349 | + | } | |||
| 7350 | + | ||||
| 7351 | + | func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_AddFavorite_Call { | |||
| 7352 | + | _c.Call.Return(favorite, err) | |||
| 7353 | + | return _c | |||
| 7354 | + | } | |||
| 7355 | + | ||||
| 7356 | + | func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call { | |||
| 7357 | + | _c.Call.Return(run) | |||
| 7358 | + | return _c | |||
| 7359 | + | } | |||
| 7360 | + | ||||
| 6963 | // AddTag provides a mock function for the type MockHomepageFavorites | 7361 | // AddTag provides a mock function for the type MockHomepageFavorites | ||
| 6964 | func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) { | 7362 | func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) { | ||
| 6965 | ret := _mock.Called(ctx, name, color) | 7363 | ret := _mock.Called(ctx, name, color) | ||
| @@ -7035,8 +7433,8 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C | |||||
| 7035 | } | 7433 | } | ||
| 7036 | 7434 | ||||
| 7037 | // CreateFolder provides a mock function for the type MockHomepageFavorites | 7435 | // CreateFolder provides a mock function for the type MockHomepageFavorites | ||
| 7038 | - | func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) { | 7436 | + | func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) { |
| 7039 | - | ret := _mock.Called(ctx, name, parentID) | 7437 | + | ret := _mock.Called(ctx, name, icon, parentID) |
| 7040 | 7438 | ||||
| 7041 | if len(ret) == 0 { | 7439 | if len(ret) == 0 { | ||
| 7042 | panic("no return value specified for CreateFolder") | 7440 | panic("no return value specified for CreateFolder") | ||
| @@ -7044,18 +7442,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin | |||||
| 7044 | 7442 | ||||
| 7045 | var r0 *entity.Folder | 7443 | var r0 *entity.Folder | ||
| 7046 | var r1 error | 7444 | var r1 error | ||
| 7047 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok { | 7445 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok { |
| 7048 | - | return returnFunc(ctx, name, parentID) | 7446 | + | return returnFunc(ctx, name, icon, parentID) |
| 7049 | } | 7447 | } | ||
| 7050 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok { | 7448 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok { |
| 7051 | - | r0 = returnFunc(ctx, name, parentID) | 7449 | + | r0 = returnFunc(ctx, name, icon, parentID) |
| 7052 | } else { | 7450 | } else { | ||
| 7053 | if ret.Get(0) != nil { | 7451 | if ret.Get(0) != nil { | ||
| 7054 | r0 = ret.Get(0).(*entity.Folder) | 7452 | r0 = ret.Get(0).(*entity.Folder) | ||
| 7055 | } | 7453 | } | ||
| 7056 | } | 7454 | } | ||
| 7057 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok { | 7455 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok { |
| 7058 | - | r1 = returnFunc(ctx, name, parentID) | 7456 | + | r1 = returnFunc(ctx, name, icon, parentID) |
| 7059 | } else { | 7457 | } else { | ||
| 7060 | r1 = ret.Error(1) | 7458 | r1 = ret.Error(1) | ||
| 7061 | } | 7459 | } | ||
| @@ -7070,12 +7468,13 @@ type MockHomepageFavorites_CreateFolder_Call struct { | |||||
| 7070 | // CreateFolder is a helper method to define mock.On call | 7468 | // CreateFolder is a helper method to define mock.On call | ||
| 7071 | // - ctx context.Context | 7469 | // - ctx context.Context | ||
| 7072 | // - name string | 7470 | // - name string | ||
| 7471 | + | // - icon string | |||
| 7073 | // - parentID *entity.FolderID | 7472 | // - parentID *entity.FolderID | ||
| 7074 | - | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call { | 7473 | + | func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call { |
| 7075 | - | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)} | 7474 | + | return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)} |
| 7076 | } | 7475 | } | ||
| 7077 | 7476 | ||||
| 7078 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { | 7477 | + | func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call { |
| 7079 | _c.Call.Run(func(args mock.Arguments) { | 7478 | _c.Call.Run(func(args mock.Arguments) { | ||
| 7080 | var arg0 context.Context | 7479 | var arg0 context.Context | ||
| 7081 | if args[0] != nil { | 7480 | if args[0] != nil { | ||
| @@ -7085,14 +7484,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont | |||||
| 7085 | if args[1] != nil { | 7484 | if args[1] != nil { | ||
| 7086 | arg1 = args[1].(string) | 7485 | arg1 = args[1].(string) | ||
| 7087 | } | 7486 | } | ||
| 7088 | - | var arg2 *entity.FolderID | 7487 | + | var arg2 string |
| 7089 | if args[2] != nil { | 7488 | if args[2] != nil { | ||
| 7090 | - | arg2 = args[2].(*entity.FolderID) | 7489 | + | arg2 = args[2].(string) |
| 7490 | + | } | |||
| 7491 | + | var arg3 *entity.FolderID | |||
| 7492 | + | if args[3] != nil { | |||
| 7493 | + | arg3 = args[3].(*entity.FolderID) | |||
| 7091 | } | 7494 | } | ||
| 7092 | run( | 7495 | run( | ||
| 7093 | arg0, | 7496 | arg0, | ||
| 7094 | arg1, | 7497 | arg1, | ||
| 7095 | arg2, | 7498 | arg2, | ||
| 7499 | + | arg3, | |||
| 7096 | ) | 7500 | ) | ||
| 7097 | }) | 7501 | }) | ||
| 7098 | return _c | 7502 | return _c | ||
| @@ -7103,7 +7507,64 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder, | |||||
| 7103 | return _c | 7507 | return _c | ||
| 7104 | } | 7508 | } | ||
| 7105 | 7509 | ||||
| 7106 | - | func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call { | 7510 | + | func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call { |
| 7511 | + | _c.Call.Return(run) | |||
| 7512 | + | return _c | |||
| 7513 | + | } | |||
| 7514 | + | ||||
| 7515 | + | // DeleteFavorite provides a mock function for the type MockHomepageFavorites | |||
| 7516 | + | func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { | |||
| 7517 | + | ret := _mock.Called(ctx, id) | |||
| 7518 | + | ||||
| 7519 | + | if len(ret) == 0 { | |||
| 7520 | + | panic("no return value specified for DeleteFavorite") | |||
| 7521 | + | } | |||
| 7522 | + | ||||
| 7523 | + | var r0 error | |||
| 7524 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID) error); ok { | |||
| 7525 | + | r0 = returnFunc(ctx, id) | |||
| 7526 | + | } else { | |||
| 7527 | + | r0 = ret.Error(0) | |||
| 7528 | + | } | |||
| 7529 | + | return r0 | |||
| 7530 | + | } | |||
| 7531 | + | ||||
| 7532 | + | // MockHomepageFavorites_DeleteFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFavorite' | |||
| 7533 | + | type MockHomepageFavorites_DeleteFavorite_Call struct { | |||
| 7534 | + | *mock.Call | |||
| 7535 | + | } | |||
| 7536 | + | ||||
| 7537 | + | // DeleteFavorite is a helper method to define mock.On call | |||
| 7538 | + | // - ctx context.Context | |||
| 7539 | + | // - id entity.FavoriteID | |||
| 7540 | + | func (_e *MockHomepageFavorites_Expecter) DeleteFavorite(ctx interface{}, id interface{}) *MockHomepageFavorites_DeleteFavorite_Call { | |||
| 7541 | + | return &MockHomepageFavorites_DeleteFavorite_Call{Call: _e.mock.On("DeleteFavorite", ctx, id)} | |||
| 7542 | + | } | |||
| 7543 | + | ||||
| 7544 | + | func (_c *MockHomepageFavorites_DeleteFavorite_Call) Run(run func(ctx context.Context, id entity.FavoriteID)) *MockHomepageFavorites_DeleteFavorite_Call { | |||
| 7545 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 7546 | + | var arg0 context.Context | |||
| 7547 | + | if args[0] != nil { | |||
| 7548 | + | arg0 = args[0].(context.Context) | |||
| 7549 | + | } | |||
| 7550 | + | var arg1 entity.FavoriteID | |||
| 7551 | + | if args[1] != nil { | |||
| 7552 | + | arg1 = args[1].(entity.FavoriteID) | |||
| 7553 | + | } | |||
| 7554 | + | run( | |||
| 7555 | + | arg0, | |||
| 7556 | + | arg1, | |||
| 7557 | + | ) | |||
| 7558 | + | }) | |||
| 7559 | + | return _c | |||
| 7560 | + | } | |||
| 7561 | + | ||||
| 7562 | + | func (_c *MockHomepageFavorites_DeleteFavorite_Call) Return(err error) *MockHomepageFavorites_DeleteFavorite_Call { | |||
| 7563 | + | _c.Call.Return(err) | |||
| 7564 | + | return _c | |||
| 7565 | + | } | |||
| 7566 | + | ||||
| 7567 | + | func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID) error) *MockHomepageFavorites_DeleteFavorite_Call { | |||
| 7107 | _c.Call.Return(run) | 7568 | _c.Call.Return(run) | ||
| 7108 | return _c | 7569 | return _c | ||
| 7109 | } | 7570 | } | ||
| @@ -7728,6 +8189,74 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co | |||||
| 7728 | return _c | 8189 | return _c | ||
| 7729 | } | 8190 | } | ||
| 7730 | 8191 | ||||
| 8192 | + | // UpdateFavorite provides a mock function for the type MockHomepageFavorites | |||
| 8193 | + | func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 8194 | + | ret := _mock.Called(ctx, input) | |||
| 8195 | + | ||||
| 8196 | + | if len(ret) == 0 { | |||
| 8197 | + | panic("no return value specified for UpdateFavorite") | |||
| 8198 | + | } | |||
| 8199 | + | ||||
| 8200 | + | var r0 *entity.Favorite | |||
| 8201 | + | var r1 error | |||
| 8202 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error)); ok { | |||
| 8203 | + | return returnFunc(ctx, input) | |||
| 8204 | + | } | |||
| 8205 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) *entity.Favorite); ok { | |||
| 8206 | + | r0 = returnFunc(ctx, input) | |||
| 8207 | + | } else { | |||
| 8208 | + | if ret.Get(0) != nil { | |||
| 8209 | + | r0 = ret.Get(0).(*entity.Favorite) | |||
| 8210 | + | } | |||
| 8211 | + | } | |||
| 8212 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteUpdateInput) error); ok { | |||
| 8213 | + | r1 = returnFunc(ctx, input) | |||
| 8214 | + | } else { | |||
| 8215 | + | r1 = ret.Error(1) | |||
| 8216 | + | } | |||
| 8217 | + | return r0, r1 | |||
| 8218 | + | } | |||
| 8219 | + | ||||
| 8220 | + | // MockHomepageFavorites_UpdateFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFavorite' | |||
| 8221 | + | type MockHomepageFavorites_UpdateFavorite_Call struct { | |||
| 8222 | + | *mock.Call | |||
| 8223 | + | } | |||
| 8224 | + | ||||
| 8225 | + | // UpdateFavorite is a helper method to define mock.On call | |||
| 8226 | + | // - ctx context.Context | |||
| 8227 | + | // - input dto.FavoriteUpdateInput | |||
| 8228 | + | func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call { | |||
| 8229 | + | return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)} | |||
| 8230 | + | } | |||
| 8231 | + | ||||
| 8232 | + | func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call { | |||
| 8233 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 8234 | + | var arg0 context.Context | |||
| 8235 | + | if args[0] != nil { | |||
| 8236 | + | arg0 = args[0].(context.Context) | |||
| 8237 | + | } | |||
| 8238 | + | var arg1 dto.FavoriteUpdateInput | |||
| 8239 | + | if args[1] != nil { | |||
| 8240 | + | arg1 = args[1].(dto.FavoriteUpdateInput) | |||
| 8241 | + | } | |||
| 8242 | + | run( | |||
| 8243 | + | arg0, | |||
| 8244 | + | arg1, | |||
| 8245 | + | ) | |||
| 8246 | + | }) | |||
| 8247 | + | return _c | |||
| 8248 | + | } | |||
| 8249 | + | ||||
| 8250 | + | func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_UpdateFavorite_Call { | |||
| 8251 | + | _c.Call.Return(favorite, err) | |||
| 8252 | + | return _c | |||
| 8253 | + | } | |||
| 8254 | + | ||||
| 8255 | + | func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call { | |||
| 8256 | + | _c.Call.Return(run) | |||
| 8257 | + | return _c | |||
| 8258 | + | } | |||
| 8259 | + | ||||
| 7731 | // UpdateFolder provides a mock function for the type MockHomepageFavorites | 8260 | // UpdateFolder provides a mock function for the type MockHomepageFavorites | ||
| 7732 | func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error { | 8261 | func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error { | ||
| 7733 | ret := _mock.Called(ctx, id, name, icon) | 8262 | ret := _mock.Called(ctx, id, name, icon) | ||
| @@ -11749,6 +12278,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context. | |||||
| 11749 | return _c | 12278 | return _c | ||
| 11750 | } | 12279 | } | ||
| 11751 | 12280 | ||||
| 12281 | + | // NewMockSystemviewConfigReader creates a new instance of MockSystemviewConfigReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12282 | + | // The first argument is typically a *testing.T value. | |||
| 12283 | + | func NewMockSystemviewConfigReader(t interface { | |||
| 12284 | + | mock.TestingT | |||
| 12285 | + | Cleanup(func()) | |||
| 12286 | + | }) *MockSystemviewConfigReader { | |||
| 12287 | + | mock := &MockSystemviewConfigReader{} | |||
| 12288 | + | mock.Mock.Test(t) | |||
| 12289 | + | ||||
| 12290 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 12291 | + | ||||
| 12292 | + | return mock | |||
| 12293 | + | } | |||
| 12294 | + | ||||
| 12295 | + | // MockSystemviewConfigReader is an autogenerated mock type for the SystemviewConfigReader type | |||
| 12296 | + | type MockSystemviewConfigReader struct { | |||
| 12297 | + | mock.Mock | |||
| 12298 | + | } | |||
| 12299 | + | ||||
| 12300 | + | type MockSystemviewConfigReader_Expecter struct { | |||
| 12301 | + | mock *mock.Mock | |||
| 12302 | + | } | |||
| 12303 | + | ||||
| 12304 | + | func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expecter { | |||
| 12305 | + | return &MockSystemviewConfigReader_Expecter{mock: &_m.Mock} | |||
| 12306 | + | } | |||
| 12307 | + | ||||
| 12308 | + | // Current provides a mock function for the type MockSystemviewConfigReader | |||
| 12309 | + | func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 12310 | + | ret := _mock.Called(ctx) | |||
| 12311 | + | ||||
| 12312 | + | if len(ret) == 0 { | |||
| 12313 | + | panic("no return value specified for Current") | |||
| 12314 | + | } | |||
| 12315 | + | ||||
| 12316 | + | var r0 dto.SystemviewConfigPayload | |||
| 12317 | + | var r1 error | |||
| 12318 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok { | |||
| 12319 | + | return returnFunc(ctx) | |||
| 12320 | + | } | |||
| 12321 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok { | |||
| 12322 | + | r0 = returnFunc(ctx) | |||
| 12323 | + | } else { | |||
| 12324 | + | r0 = ret.Get(0).(dto.SystemviewConfigPayload) | |||
| 12325 | + | } | |||
| 12326 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 12327 | + | r1 = returnFunc(ctx) | |||
| 12328 | + | } else { | |||
| 12329 | + | r1 = ret.Error(1) | |||
| 12330 | + | } | |||
| 12331 | + | return r0, r1 | |||
| 12332 | + | } | |||
| 12333 | + | ||||
| 12334 | + | // MockSystemviewConfigReader_Current_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Current' | |||
| 12335 | + | type MockSystemviewConfigReader_Current_Call struct { | |||
| 12336 | + | *mock.Call | |||
| 12337 | + | } | |||
| 12338 | + | ||||
| 12339 | + | // Current is a helper method to define mock.On call | |||
| 12340 | + | // - ctx context.Context | |||
| 12341 | + | func (_e *MockSystemviewConfigReader_Expecter) Current(ctx interface{}) *MockSystemviewConfigReader_Current_Call { | |||
| 12342 | + | return &MockSystemviewConfigReader_Current_Call{Call: _e.mock.On("Current", ctx)} | |||
| 12343 | + | } | |||
| 12344 | + | ||||
| 12345 | + | func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Current_Call { | |||
| 12346 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 12347 | + | var arg0 context.Context | |||
| 12348 | + | if args[0] != nil { | |||
| 12349 | + | arg0 = args[0].(context.Context) | |||
| 12350 | + | } | |||
| 12351 | + | run( | |||
| 12352 | + | arg0, | |||
| 12353 | + | ) | |||
| 12354 | + | }) | |||
| 12355 | + | return _c | |||
| 12356 | + | } | |||
| 12357 | + | ||||
| 12358 | + | func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call { | |||
| 12359 | + | _c.Call.Return(systemviewConfigPayload, err) | |||
| 12360 | + | return _c | |||
| 12361 | + | } | |||
| 12362 | + | ||||
| 12363 | + | func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call { | |||
| 12364 | + | _c.Call.Return(run) | |||
| 12365 | + | return _c | |||
| 12366 | + | } | |||
| 12367 | + | ||||
| 12368 | + | // Default provides a mock function for the type MockSystemviewConfigReader | |||
| 12369 | + | func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 12370 | + | ret := _mock.Called(ctx) | |||
| 12371 | + | ||||
| 12372 | + | if len(ret) == 0 { | |||
| 12373 | + | panic("no return value specified for Default") | |||
| 12374 | + | } | |||
| 12375 | + | ||||
| 12376 | + | var r0 dto.SystemviewConfigPayload | |||
| 12377 | + | var r1 error | |||
| 12378 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok { | |||
| 12379 | + | return returnFunc(ctx) | |||
| 12380 | + | } | |||
| 12381 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok { | |||
| 12382 | + | r0 = returnFunc(ctx) | |||
| 12383 | + | } else { | |||
| 12384 | + | r0 = ret.Get(0).(dto.SystemviewConfigPayload) | |||
| 12385 | + | } | |||
| 12386 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 12387 | + | r1 = returnFunc(ctx) | |||
| 12388 | + | } else { | |||
| 12389 | + | r1 = ret.Error(1) | |||
| 12390 | + | } | |||
| 12391 | + | return r0, r1 | |||
| 12392 | + | } | |||
| 12393 | + | ||||
| 12394 | + | // MockSystemviewConfigReader_Default_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Default' | |||
| 12395 | + | type MockSystemviewConfigReader_Default_Call struct { | |||
| 12396 | + | *mock.Call | |||
| 12397 | + | } | |||
| 12398 | + | ||||
| 12399 | + | // Default is a helper method to define mock.On call | |||
| 12400 | + | // - ctx context.Context | |||
| 12401 | + | func (_e *MockSystemviewConfigReader_Expecter) Default(ctx interface{}) *MockSystemviewConfigReader_Default_Call { | |||
| 12402 | + | return &MockSystemviewConfigReader_Default_Call{Call: _e.mock.On("Default", ctx)} | |||
| 12403 | + | } | |||
| 12404 | + | ||||
| 12405 | + | func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Default_Call { | |||
| 12406 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 12407 | + | var arg0 context.Context | |||
| 12408 | + | if args[0] != nil { | |||
| 12409 | + | arg0 = args[0].(context.Context) | |||
| 12410 | + | } | |||
| 12411 | + | run( | |||
| 12412 | + | arg0, | |||
| 12413 | + | ) | |||
| 12414 | + | }) | |||
| 12415 | + | return _c | |||
| 12416 | + | } | |||
| 12417 | + | ||||
| 12418 | + | func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call { | |||
| 12419 | + | _c.Call.Return(systemviewConfigPayload, err) | |||
| 12420 | + | return _c | |||
| 12421 | + | } | |||
| 12422 | + | ||||
| 12423 | + | func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call { | |||
| 12424 | + | _c.Call.Return(run) | |||
| 12425 | + | return _c | |||
| 12426 | + | } | |||
| 12427 | + | ||||
| 11752 | // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 12428 | // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 11753 | // The first argument is typically a *testing.T value. | 12429 | // The first argument is typically a *testing.T value. | ||
| 11754 | func NewMockWebUIConfigSaver(t interface { | 12430 | func NewMockWebUIConfigSaver(t interface { | ||
| @@ -11777,7 +12453,7 @@ func (_m *MockWebUIConfigSaver) EXPECT() *MockWebUIConfigSaver_Expecter { | |||||
| 11777 | } | 12453 | } | ||
| 11778 | 12454 | ||||
| 11779 | // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver | 12455 | // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver | ||
| 11780 | - | func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | 12456 | + | func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error { |
| 11781 | ret := _mock.Called(ctx, cfg) | 12457 | ret := _mock.Called(ctx, cfg) | ||
| 11782 | 12458 | ||||
| 11783 | if len(ret) == 0 { | 12459 | if len(ret) == 0 { | ||
| @@ -11785,7 +12461,7 @@ func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port | |||||
| 11785 | } | 12461 | } | ||
| 11786 | 12462 | ||||
| 11787 | var r0 error | 12463 | var r0 error | ||
| 11788 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebUIConfig) error); ok { | 12464 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, dto.WebUIConfig) error); ok { |
| 11789 | r0 = returnFunc(ctx, cfg) | 12465 | r0 = returnFunc(ctx, cfg) | ||
| 11790 | } else { | 12466 | } else { | ||
| 11791 | r0 = ret.Error(0) | 12467 | r0 = ret.Error(0) | ||
| @@ -11800,20 +12476,20 @@ type MockWebUIConfigSaver_SaveWebUIConfig_Call struct { | |||||
| 11800 | 12476 | ||||
| 11801 | // SaveWebUIConfig is a helper method to define mock.On call | 12477 | // SaveWebUIConfig is a helper method to define mock.On call | ||
| 11802 | // - ctx context.Context | 12478 | // - ctx context.Context | ||
| 11803 | - | // - cfg port.WebUIConfig | 12479 | + | // - cfg dto.WebUIConfig |
| 11804 | func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call { | 12480 | func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call { | ||
| 11805 | return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)} | 12481 | return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)} | ||
| 11806 | } | 12482 | } | ||
| 11807 | 12483 | ||||
| 11808 | - | func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg port.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call { | 12484 | + | func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg dto.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call { |
| 11809 | _c.Call.Run(func(args mock.Arguments) { | 12485 | _c.Call.Run(func(args mock.Arguments) { | ||
| 11810 | var arg0 context.Context | 12486 | var arg0 context.Context | ||
| 11811 | if args[0] != nil { | 12487 | if args[0] != nil { | ||
| 11812 | arg0 = args[0].(context.Context) | 12488 | arg0 = args[0].(context.Context) | ||
| 11813 | } | 12489 | } | ||
| 11814 | - | var arg1 port.WebUIConfig | 12490 | + | var arg1 dto.WebUIConfig |
| 11815 | if args[1] != nil { | 12491 | if args[1] != nil { | ||
| 11816 | - | arg1 = args[1].(port.WebUIConfig) | 12492 | + | arg1 = args[1].(dto.WebUIConfig) |
| 11817 | } | 12493 | } | ||
| 11818 | run( | 12494 | run( | ||
| 11819 | arg0, | 12495 | arg0, | ||
| @@ -11828,7 +12504,7 @@ func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Return(err error) *MockWebU | |||||
| 11828 | return _c | 12504 | return _c | ||
| 11829 | } | 12505 | } | ||
| 11830 | 12506 | ||||
| 11831 | - | func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg port.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call { | 12507 | + | func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg dto.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call { |
| 11832 | _c.Call.Return(run) | 12508 | _c.Call.Return(run) | ||
| 11833 | return _c | 12509 | return _c | ||
| 11834 | } | 12510 | } | ||
A internal/application/port/systemviews.go +54 -0
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // SystemviewHistoryService exposes history operations for the systemviews history route. | |||
| 12 | + | type SystemviewHistoryService interface { | |||
| 13 | + | Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |||
| 14 | + | TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |||
| 15 | + | TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) | |||
| 16 | + | Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) | |||
| 17 | + | DeleteEntry(ctx context.Context, id int64) error | |||
| 18 | + | DeleteRange(ctx context.Context, rangeID string) error | |||
| 19 | + | Stats(ctx context.Context) (*entity.HistoryStats, error) | |||
| 20 | + | Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) | |||
| 21 | + | DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) | |||
| 22 | + | DeleteDomain(ctx context.Context, domain string) error | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // SystemviewFavoritesService exposes favorite, folder, and tag operations. | |||
| 26 | + | type SystemviewFavoritesService interface { | |||
| 27 | + | List(ctx context.Context) ([]*entity.Favorite, error) | |||
| 28 | + | CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) | |||
| 29 | + | UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) | |||
| 30 | + | DeleteFavorite(ctx context.Context, id int64) error | |||
| 31 | + | ListFolders(ctx context.Context) ([]*entity.Folder, error) | |||
| 32 | + | ListTags(ctx context.Context) ([]*entity.Tag, error) | |||
| 33 | + | SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error | |||
| 34 | + | SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error | |||
| 35 | + | CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) | |||
| 36 | + | UpdateFolder(ctx context.Context, id int64, name, icon string) error | |||
| 37 | + | DeleteFolder(ctx context.Context, id int64) error | |||
| 38 | + | CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) | |||
| 39 | + | UpdateTag(ctx context.Context, id int64, name, color string) error | |||
| 40 | + | DeleteTag(ctx context.Context, id int64) error | |||
| 41 | + | AssignTag(ctx context.Context, favoriteID, tagID int64) error | |||
| 42 | + | RemoveTag(ctx context.Context, favoriteID, tagID int64) error | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // SystemviewConfigService exposes config and keybinding operations for the systemviews UI. | |||
| 46 | + | type SystemviewConfigService interface { | |||
| 47 | + | Current(ctx context.Context) (dto.SystemviewConfigPayload, error) | |||
| 48 | + | Default(ctx context.Context) (dto.SystemviewConfigPayload, error) | |||
| 49 | + | Save(ctx context.Context, cfg dto.WebUIConfig) error | |||
| 50 | + | GetKeybindings(ctx context.Context) (KeybindingsConfig, error) | |||
| 51 | + | SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error) | |||
| 52 | + | ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error | |||
| 53 | + | ResetAllKeybindings(ctx context.Context) error | |||
| 54 | + | } | |||
M internal/application/port/webui_config.go +9 -48
| @@ -1,57 +1,18 @@ | |||||
| 1 | package port | 1 | package port | ||
| 2 | 2 | ||||
| 3 | - | import "context" | 3 | + | import ( |
| 4 | + | "context" | |||
| 4 | 5 | ||||
| 5 | - | // WebUIConfig represents the subset of configuration editable in dumb://config. | 6 | + | "github.com/bnema/dumber/internal/application/dto" |
| 6 | - | type WebUIConfig struct { | 7 | + | ) |
| 7 | - | Appearance WebUIAppearanceConfig `json:"appearance"` | |||
| 8 | - | Performance WebUIPerformanceConfig `json:"performance"` | |||
| 9 | - | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 10 | - | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 11 | - | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | // WebUIPerformanceConfig represents performance settings editable in dumb://config. | |||
| 15 | - | type WebUIPerformanceConfig struct { | |||
| 16 | - | Profile string `json:"profile"` | |||
| 17 | - | Custom WebUICustomPerformanceConfig `json:"custom"` | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // WebUICustomPerformanceConfig holds user-editable fields for custom profile. | |||
| 21 | - | type WebUICustomPerformanceConfig struct { | |||
| 22 | - | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 23 | - | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 24 | - | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 25 | - | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 26 | - | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | type WebUIAppearanceConfig struct { | |||
| 30 | - | SansFont string `json:"sans_font"` | |||
| 31 | - | SerifFont string `json:"serif_font"` | |||
| 32 | - | MonospaceFont string `json:"monospace_font"` | |||
| 33 | - | DefaultFontSize int `json:"default_font_size"` | |||
| 34 | - | ColorScheme string `json:"color_scheme"` | |||
| 35 | - | LightPalette ColorPalette `json:"light_palette"` | |||
| 36 | - | DarkPalette ColorPalette `json:"dark_palette"` | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | type ColorPalette struct { | |||
| 40 | - | Background string `json:"background"` | |||
| 41 | - | Surface string `json:"surface"` | |||
| 42 | - | SurfaceVariant string `json:"surface_variant"` | |||
| 43 | - | Text string `json:"text"` | |||
| 44 | - | Muted string `json:"muted"` | |||
| 45 | - | Accent string `json:"accent"` | |||
| 46 | - | Border string `json:"border"` | |||
| 47 | - | } | |||
| 48 | 8 | ||||
| 49 | - | type SearchShortcut struct { | 9 | + | // SystemviewConfigReader reads the current and default systemview config payloads. |
| 50 | - | URL string `json:"url"` | 10 | + | type SystemviewConfigReader interface { |
| 51 | - | Description string `json:"description"` | 11 | + | Current(ctx context.Context) (dto.SystemviewConfigPayload, error) |
| 12 | + | Default(ctx context.Context) (dto.SystemviewConfigPayload, error) | |||
| 52 | } | 13 | } | ||
| 53 | 14 | ||||
| 54 | // WebUIConfigSaver persists WebUI configuration changes. | 15 | // WebUIConfigSaver persists WebUI configuration changes. | ||
| 55 | type WebUIConfigSaver interface { | 16 | type WebUIConfigSaver interface { | ||
| 56 | - | SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error | 17 | + | SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error |
| 57 | } | 18 | } | ||
M internal/application/usecase/manage_favorites.go +161 -24
| @@ -3,11 +3,13 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "net/url" | |||
| 6 | "sort" | 7 | "sort" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| 9 | "time" | 10 | "time" | ||
| 10 | 11 | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | "github.com/bnema/dumber/internal/application/port" | 13 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/domain/entity" | 14 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 13 | "github.com/bnema/dumber/internal/domain/repository" | 15 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -69,6 +71,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 69 | return existing, nil | 71 | return existing, nil | ||
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| 74 | + | return uc.addNewFavorite(ctx, input) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) { | |||
| 78 | + | log := logging.FromContext(ctx) | |||
| 72 | fav := entity.NewFavorite(input.URL, input.Title) | 79 | fav := entity.NewFavorite(input.URL, input.Title) | ||
| 73 | fav.FaviconURL = input.FaviconURL | 80 | fav.FaviconURL = input.FaviconURL | ||
| 74 | fav.FolderID = input.FolderID | 81 | fav.FolderID = input.FolderID | ||
| @@ -93,6 +100,124 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu | |||||
| 93 | return fav, nil | 100 | return fav, nil | ||
| 94 | } | 101 | } | ||
| 95 | 102 | ||||
| 103 | + | // AddFavorite creates a favorite from the UI-facing application port. | |||
| 104 | + | func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 105 | + | for _, tagID := range input.Tags { | |||
| 106 | + | if tagID <= 0 { | |||
| 107 | + | return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | favoriteURL, err := normalizeFavoriteURL(input.URL) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return nil, err | |||
| 113 | + | } | |||
| 114 | + | if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil { | |||
| 115 | + | return nil, err | |||
| 116 | + | } else if existing != nil { | |||
| 117 | + | return existing, nil | |||
| 118 | + | } | |||
| 119 | + | tags := append([]entity.TagID(nil), input.Tags...) | |||
| 120 | + | title := strings.TrimSpace(input.Title) | |||
| 121 | + | if title == "" { | |||
| 122 | + | title = favoriteURL | |||
| 123 | + | } | |||
| 124 | + | return uc.addNewFavorite(ctx, AddFavoriteInput{ | |||
| 125 | + | URL: favoriteURL, | |||
| 126 | + | Title: title, | |||
| 127 | + | FaviconURL: strings.TrimSpace(input.FaviconURL), | |||
| 128 | + | FolderID: input.FolderID, | |||
| 129 | + | Tags: tags, | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL( | |||
| 134 | + | ctx context.Context, | |||
| 135 | + | canonicalURL string, | |||
| 136 | + | rawURL string, | |||
| 137 | + | ) (*entity.Favorite, error) { | |||
| 138 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |||
| 141 | + | } | |||
| 142 | + | if existing != nil { | |||
| 143 | + | return existing, nil | |||
| 144 | + | } | |||
| 145 | + | rawURL = strings.TrimSpace(rawURL) | |||
| 146 | + | if rawURL == "" || rawURL == canonicalURL { | |||
| 147 | + | return nil, nil | |||
| 148 | + | } | |||
| 149 | + | existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return nil, fmt.Errorf("failed to check legacy favorite: %w", err) | |||
| 152 | + | } | |||
| 153 | + | return existing, nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func normalizeFavoriteURL(raw string) (string, error) { | |||
| 157 | + | favoriteURL := domainurl.Normalize(strings.TrimSpace(raw)) | |||
| 158 | + | if favoriteURL == "" { | |||
| 159 | + | return "", fmt.Errorf("favorite URL is required") | |||
| 160 | + | } | |||
| 161 | + | parsed, err := url.Parse(favoriteURL) | |||
| 162 | + | if err != nil || parsed.Scheme == "" { | |||
| 163 | + | return "", fmt.Errorf("favorite URL must be absolute") | |||
| 164 | + | } | |||
| 165 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 166 | + | case "http", "https": | |||
| 167 | + | if parsed.Host == "" { | |||
| 168 | + | return "", fmt.Errorf("favorite URL host is required") | |||
| 169 | + | } | |||
| 170 | + | case "dumb": | |||
| 171 | + | if parsed.Host != "" { | |||
| 172 | + | return favoriteURL, nil | |||
| 173 | + | } | |||
| 174 | + | if parsed.Opaque != "" { | |||
| 175 | + | return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil | |||
| 176 | + | } | |||
| 177 | + | return "", fmt.Errorf("favorite URL host is required") | |||
| 178 | + | default: | |||
| 179 | + | return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") | |||
| 180 | + | } | |||
| 181 | + | return favoriteURL, nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // UpdateFavorite updates editable favorite metadata from the UI-facing application port. | |||
| 185 | + | func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 186 | + | if input.ID <= 0 { | |||
| 187 | + | return nil, fmt.Errorf("favorite ID is required") | |||
| 188 | + | } | |||
| 189 | + | if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) { | |||
| 190 | + | return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | fav, err := uc.favoriteRepo.FindByID(ctx, input.ID) | |||
| 194 | + | if err != nil { | |||
| 195 | + | return nil, fmt.Errorf("failed to find favorite: %w", err) | |||
| 196 | + | } | |||
| 197 | + | if fav == nil { | |||
| 198 | + | return nil, fmt.Errorf("favorite %d not found", input.ID) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | title := strings.TrimSpace(input.Title) | |||
| 202 | + | if title == "" { | |||
| 203 | + | return nil, fmt.Errorf("favorite title is required") | |||
| 204 | + | } | |||
| 205 | + | fav.Title = title | |||
| 206 | + | fav.FaviconURL = strings.TrimSpace(input.FaviconURL) | |||
| 207 | + | fav.FolderID = input.FolderID | |||
| 208 | + | fav.ShortcutKey = input.ShortcutKey | |||
| 209 | + | ||||
| 210 | + | if err := uc.Update(ctx, fav); err != nil { | |||
| 211 | + | return nil, err | |||
| 212 | + | } | |||
| 213 | + | return fav, nil | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // DeleteFavorite deletes a favorite from the UI-facing application port. | |||
| 217 | + | func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error { | |||
| 218 | + | return uc.Remove(ctx, id) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 96 | // Remove deletes a favorite by ID. | 221 | // Remove deletes a favorite by ID. | ||
| 97 | func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error { | 222 | func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error { | ||
| 98 | log := logging.FromContext(ctx) | 223 | log := logging.FromContext(ctx) | ||
| @@ -108,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite | |||||
| 108 | } | 233 | } | ||
| 109 | 234 | ||||
| 110 | // RemoveByURL deletes a favorite by its URL. | 235 | // RemoveByURL deletes a favorite by its URL. | ||
| 111 | - | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error { | 236 | + | func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error { |
| 112 | log := logging.FromContext(ctx) | 237 | log := logging.FromContext(ctx) | ||
| 113 | - | log.Debug().Str("url", url).Msg("removing favorite by URL") | 238 | + | log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL") |
| 114 | 239 | ||||
| 115 | - | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | 240 | + | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) |
| 116 | if err != nil { | 241 | if err != nil { | ||
| 117 | return fmt.Errorf("failed to find favorite: %w", err) | 242 | return fmt.Errorf("failed to find favorite: %w", err) | ||
| 118 | } | 243 | } | ||
| 119 | if fav == nil { | 244 | if fav == nil { | ||
| 120 | - | log.Debug().Str("url", url).Msg("favorite not found") | 245 | + | log.Debug().Str("url", favoriteURL).Msg("favorite not found") |
| 121 | return nil | 246 | return nil | ||
| 122 | } | 247 | } | ||
| 123 | 248 | ||||
| @@ -187,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (* | |||||
| 187 | } | 312 | } | ||
| 188 | 313 | ||||
| 189 | // GetByURL finds a favorite by its URL. | 314 | // GetByURL finds a favorite by its URL. | ||
| 190 | - | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) { | 315 | + | func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) { |
| 191 | log := logging.FromContext(ctx) | 316 | log := logging.FromContext(ctx) | ||
| 192 | - | log.Debug().Str("url", url).Msg("getting favorite by URL") | 317 | + | log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL") |
| 193 | 318 | ||||
| 194 | - | return uc.favoriteRepo.FindByURL(ctx, url) | 319 | + | return uc.favoriteRepo.FindByURL(ctx, favoriteURL) |
| 195 | } | 320 | } | ||
| 196 | 321 | ||||
| 197 | // GetAll retrieves all favorites. | 322 | // GetAll retrieves all favorites. | ||
| @@ -268,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st | |||||
| 268 | } | 393 | } | ||
| 269 | 394 | ||||
| 270 | // IsFavorite checks if a URL is favorited. | 395 | // IsFavorite checks if a URL is favorited. | ||
| 271 | - | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) { | 396 | + | func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) { |
| 272 | - | fav, err := uc.favoriteRepo.FindByURL(ctx, url) | 397 | + | fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL) |
| 273 | if err != nil { | 398 | if err != nil { | ||
| 274 | return false, fmt.Errorf("failed to check favorite: %w", err) | 399 | return false, fmt.Errorf("failed to check favorite: %w", err) | ||
| 275 | } | 400 | } | ||
| @@ -322,18 +447,20 @@ type ToggleResult struct { | |||||
| 322 | 447 | ||||
| 323 | // Toggle adds or removes a URL from favorites based on current state. | 448 | // Toggle adds or removes a URL from favorites based on current state. | ||
| 324 | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | 449 | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | ||
| 325 | - | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | 450 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) { |
| 326 | - | log := logging.FromContext(ctx) | 451 | + | favoriteURL, err := normalizeFavoriteURL(rawURL) |
| 327 | - | log.Debug().Str("url", url).Msg("toggling favorite") | 452 | + | if err != nil { |
| 328 | - | 453 | + | return nil, err | |
| 329 | - | if url == "" { | |||
| 330 | - | return nil, fmt.Errorf("URL cannot be empty") | |||
| 331 | } | 454 | } | ||
| 455 | + | title = strings.TrimSpace(title) | |||
| 332 | 456 | ||||
| 333 | - | // Check if already favorited | 457 | + | log := logging.FromContext(ctx) |
| 334 | - | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | 458 | + | log.Debug().Str("url", favoriteURL).Msg("toggling favorite") |
| 459 | + | ||||
| 460 | + | // Check if already favorited, including legacy rows stored before URL canonicalization. | |||
| 461 | + | existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL) | |||
| 335 | if err != nil { | 462 | if err != nil { | ||
| 336 | - | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | 463 | + | return nil, err |
| 337 | } | 464 | } | ||
| 338 | 465 | ||||
| 339 | if existing != nil { | 466 | if existing != nil { | ||
| @@ -341,26 +468,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) | |||||
| 341 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | 468 | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | ||
| 342 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | 469 | return nil, fmt.Errorf("failed to remove favorite: %w", err) | ||
| 343 | } | 470 | } | ||
| 344 | - | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | 471 | + | log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") |
| 345 | uc.invalidateCache() | 472 | uc.invalidateCache() | ||
| 346 | return &ToggleResult{ | 473 | return &ToggleResult{ | ||
| 347 | Added: false, | 474 | Added: false, | ||
| 348 | - | URL: url, | 475 | + | URL: favoriteURL, |
| 349 | Title: existing.Title, | 476 | Title: existing.Title, | ||
| 350 | Message: "Favorite removed", | 477 | Message: "Favorite removed", | ||
| 351 | }, nil | 478 | }, nil | ||
| 352 | } | 479 | } | ||
| 353 | 480 | ||||
| 354 | // Add favorite | 481 | // Add favorite | ||
| 355 | - | fav := entity.NewFavorite(url, title) | 482 | + | fav := entity.NewFavorite(favoriteURL, title) |
| 356 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | 483 | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | ||
| 357 | return nil, fmt.Errorf("failed to add favorite: %w", err) | 484 | return nil, fmt.Errorf("failed to add favorite: %w", err) | ||
| 358 | } | 485 | } | ||
| 359 | - | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | 486 | + | log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") |
| 360 | uc.invalidateCache() | 487 | uc.invalidateCache() | ||
| 361 | return &ToggleResult{ | 488 | return &ToggleResult{ | ||
| 362 | Added: true, | 489 | Added: true, | ||
| 363 | - | URL: url, | 490 | + | URL: favoriteURL, |
| 364 | Title: title, | 491 | Title: title, | ||
| 365 | Message: "Favorite added", | 492 | Message: "Favorite added", | ||
| 366 | }, nil | 493 | }, nil | ||
| @@ -456,11 +583,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite | |||||
| 456 | } | 583 | } | ||
| 457 | 584 | ||||
| 458 | // CreateFolder creates a new folder. | 585 | // CreateFolder creates a new folder. | ||
| 459 | - | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) { | 586 | + | func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) { |
| 587 | + | name = strings.TrimSpace(name) | |||
| 588 | + | icon = strings.TrimSpace(icon) | |||
| 589 | + | if name == "" { | |||
| 590 | + | return nil, fmt.Errorf("folder name is required") | |||
| 591 | + | } | |||
| 460 | log := logging.FromContext(ctx) | 592 | log := logging.FromContext(ctx) | ||
| 461 | log.Debug().Str("name", name).Msg("creating folder") | 593 | log.Debug().Str("name", name).Msg("creating folder") | ||
| 462 | 594 | ||||
| 463 | folder := entity.NewFolder(name) | 595 | folder := entity.NewFolder(name) | ||
| 596 | + | folder.Icon = icon | |||
| 464 | folder.ParentID = parentID | 597 | folder.ParentID = parentID | ||
| 465 | 598 | ||||
| 466 | if err := uc.folderRepo.Save(ctx, folder); err != nil { | 599 | if err := uc.folderRepo.Save(ctx, folder); err != nil { | ||
| @@ -574,6 +707,7 @@ func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID | |||||
| 574 | } | 707 | } | ||
| 575 | 708 | ||||
| 576 | log.Info().Int64("id", int64(id)).Msg("tag deleted") | 709 | log.Info().Int64("id", int64(id)).Msg("tag deleted") | ||
| 710 | + | uc.invalidateCache() | |||
| 577 | return nil | 711 | return nil | ||
| 578 | } | 712 | } | ||
| 579 | 713 | ||||
| @@ -596,6 +730,7 @@ func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity. | |||||
| 596 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil { | 730 | if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil { | ||
| 597 | return fmt.Errorf("failed to tag favorite: %w", err) | 731 | return fmt.Errorf("failed to tag favorite: %w", err) | ||
| 598 | } | 732 | } | ||
| 733 | + | uc.invalidateCache() | |||
| 599 | 734 | ||||
| 600 | return nil | 735 | return nil | ||
| 601 | } | 736 | } | ||
| @@ -611,6 +746,7 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit | |||||
| 611 | if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil { | 746 | if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil { | ||
| 612 | return fmt.Errorf("failed to untag favorite: %w", err) | 747 | return fmt.Errorf("failed to untag favorite: %w", err) | ||
| 613 | } | 748 | } | ||
| 749 | + | uc.invalidateCache() | |||
| 614 | 750 | ||||
| 615 | return nil | 751 | return nil | ||
| 616 | } | 752 | } | ||
| @@ -674,5 +810,6 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID | |||||
| 674 | } | 810 | } | ||
| 675 | 811 | ||||
| 676 | log.Info().Int64("id", int64(id)).Msg("tag updated") | 812 | log.Info().Int64("id", int64(id)).Msg("tag updated") | ||
| 813 | + | uc.invalidateCache() | |||
| 677 | return nil | 814 | return nil | ||
| 678 | } | 815 | } | ||
M internal/application/usecase/manage_favorites_test.go +105 -1
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 8 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | 10 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | ||
| @@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |||||
| 142 | result, err := uc.Toggle(ctx, "", "Title") | 143 | result, err := uc.Toggle(ctx, "", "Title") | ||
| 143 | require.Error(t, err) | 144 | require.Error(t, err) | ||
| 144 | assert.Nil(t, result) | 145 | assert.Nil(t, result) | ||
| 145 | - | assert.Contains(t, err.Error(), "URL cannot be empty") | 146 | + | assert.Contains(t, err.Error(), "favorite URL is required") |
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) { | |||
| 150 | + | ctx := testContext() | |||
| 151 | + | ||||
| 152 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 153 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 154 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 155 | + | ||||
| 156 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |||
| 157 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once() | |||
| 158 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 159 | + | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | |||
| 160 | + | })).Return(nil).Once() | |||
| 161 | + | ||||
| 162 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 163 | + | ||||
| 164 | + | result, err := uc.Toggle(ctx, " example.com ", " Example ") | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | require.NotNil(t, result) | |||
| 167 | + | assert.True(t, result.Added) | |||
| 168 | + | assert.Equal(t, "https://example.com", result.URL) | |||
| 169 | + | assert.Equal(t, "Example", result.Title) | |||
| 146 | } | 170 | } | ||
| 147 | 171 | ||||
| 148 | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | 172 | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | ||
| @@ -342,3 +366,83 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin | |||||
| 342 | require.Len(t, favoritesAfter, 1) | 366 | require.Len(t, favoritesAfter, 1) | ||
| 343 | assert.Nil(t, favoritesAfter[0].FolderID) | 367 | assert.Nil(t, favoritesAfter[0].FolderID) | ||
| 344 | } | 368 | } | ||
| 369 | + | ||||
| 370 | + | func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) { | |||
| 371 | + | ctx := testContext() | |||
| 372 | + | ||||
| 373 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 374 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 375 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 376 | + | ||||
| 377 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once() | |||
| 378 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once() | |||
| 379 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 380 | + | return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" | |||
| 381 | + | })).Return(nil).Once() | |||
| 382 | + | ||||
| 383 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 384 | + | ||||
| 385 | + | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "}) | |||
| 386 | + | require.NoError(t, err) | |||
| 387 | + | require.NotNil(t, fav) | |||
| 388 | + | assert.Equal(t, "https://example.com", fav.URL) | |||
| 389 | + | assert.Equal(t, "Example", fav.Title) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) { | |||
| 393 | + | ctx := testContext() | |||
| 394 | + | ||||
| 395 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 396 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 397 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 398 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 399 | + | ||||
| 400 | + | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}}) | |||
| 401 | + | require.Error(t, err) | |||
| 402 | + | assert.Contains(t, err.Error(), "favorite tag id must be positive") | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) { | |||
| 406 | + | ctx := testContext() | |||
| 407 | + | ||||
| 408 | + | tests := []string{ | |||
| 409 | + | "", | |||
| 410 | + | "not a url", | |||
| 411 | + | "javascript:alert(1)", | |||
| 412 | + | "data:text/html,<script>alert(1)</script>", | |||
| 413 | + | "https:///missing-host", | |||
| 414 | + | "dumb:///missing-host", | |||
| 415 | + | } | |||
| 416 | + | for _, raw := range tests { | |||
| 417 | + | t.Run(raw, func(t *testing.T) { | |||
| 418 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 419 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 420 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 421 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 422 | + | ||||
| 423 | + | _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw}) | |||
| 424 | + | require.Error(t, err) | |||
| 425 | + | }) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) { | |||
| 430 | + | ctx := testContext() | |||
| 431 | + | ||||
| 432 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |||
| 433 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |||
| 434 | + | tagRepo := repomocks.NewMockTagRepository(t) | |||
| 435 | + | ||||
| 436 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once() | |||
| 437 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once() | |||
| 438 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool { | |||
| 439 | + | return fav != nil && fav.URL == "dumb://history" && fav.Title == "History" | |||
| 440 | + | })).Return(nil).Once() | |||
| 441 | + | ||||
| 442 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 443 | + | ||||
| 444 | + | fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"}) | |||
| 445 | + | require.NoError(t, err) | |||
| 446 | + | require.NotNil(t, fav) | |||
| 447 | + | assert.Equal(t, "dumb://history", fav.URL) | |||
| 448 | + | } | |||
A internal/application/usecase/read_systemview_config.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ReadSystemviewConfigUseCase exposes read-side systemview config payloads. | |||
| 12 | + | type ReadSystemviewConfigUseCase struct { | |||
| 13 | + | reader port.SystemviewConfigReader | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewReadSystemviewConfigUseCase creates a new read systemview config use case. | |||
| 17 | + | func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase { | |||
| 18 | + | if reader == nil { | |||
| 19 | + | panic("NewReadSystemviewConfigUseCase: reader is nil") | |||
| 20 | + | } | |||
| 21 | + | return &ReadSystemviewConfigUseCase{reader: reader} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil. | |||
| 25 | + | var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil") | |||
| 26 | + | ||||
| 27 | + | // Current returns the current systemview config payload. | |||
| 28 | + | func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 29 | + | if uc == nil || uc.reader == nil { | |||
| 30 | + | return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader | |||
| 31 | + | } | |||
| 32 | + | return uc.reader.Current(ctx) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Default returns the default systemview config payload. | |||
| 36 | + | func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 37 | + | if uc == nil || uc.reader == nil { | |||
| 38 | + | return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader | |||
| 39 | + | } | |||
| 40 | + | return uc.reader.Default(ctx) | |||
| 41 | + | } | |||
A internal/application/usecase/read_systemview_config_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestReadSystemviewConfigUseCase_Current(t *testing.T) { | |||
| 14 | + | reader := portmocks.NewMockSystemviewConfigReader(t) | |||
| 15 | + | want := dto.SystemviewConfigPayload{EngineType: "webkit"} | |||
| 16 | + | reader.EXPECT().Current(mock.Anything).Return(want, nil).Once() | |||
| 17 | + | ||||
| 18 | + | uc := NewReadSystemviewConfigUseCase(reader) | |||
| 19 | + | ||||
| 20 | + | got, err := uc.Current(context.Background()) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | require.Equal(t, want, got) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestReadSystemviewConfigUseCase_Default(t *testing.T) { | |||
| 26 | + | reader := portmocks.NewMockSystemviewConfigReader(t) | |||
| 27 | + | want := dto.SystemviewConfigPayload{EngineType: "webkit"} | |||
| 28 | + | reader.EXPECT().Default(mock.Anything).Return(want, nil).Once() | |||
| 29 | + | ||||
| 30 | + | uc := NewReadSystemviewConfigUseCase(reader) | |||
| 31 | + | ||||
| 32 | + | got, err := uc.Default(context.Background()) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.Equal(t, want, got) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) { | |||
| 38 | + | require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() { | |||
| 39 | + | NewReadSystemviewConfigUseCase(nil) | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) { | |||
| 44 | + | var uc *ReadSystemviewConfigUseCase | |||
| 45 | + | ||||
| 46 | + | _, err := uc.Current(context.Background()) | |||
| 47 | + | require.ErrorIs(t, err, ErrNilSystemviewConfigReader) | |||
| 48 | + | ||||
| 49 | + | _, err = uc.Default(context.Background()) | |||
| 50 | + | require.ErrorIs(t, err, ErrNilSystemviewConfigReader) | |||
| 51 | + | } | |||
M internal/application/usecase/save_webui_config.go +43 -13
| @@ -3,8 +3,10 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/domain/validation" | 11 | "github.com/bnema/dumber/internal/domain/validation" | ||
| 10 | ) | 12 | ) | ||
| @@ -17,11 +19,15 @@ func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseC | |||||
| 17 | return &SaveWebUIConfigUseCase{saver: saver} | 19 | return &SaveWebUIConfigUseCase{saver: saver} | ||
| 18 | } | 20 | } | ||
| 19 | 21 | ||||
| 20 | - | func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error { | 22 | + | func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error { |
| 21 | if uc == nil || uc.saver == nil { | 23 | if uc == nil || uc.saver == nil { | ||
| 22 | return fmt.Errorf("config saver is nil") | 24 | return fmt.Errorf("config saver is nil") | ||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 27 | + | if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil { | |||
| 28 | + | return err | |||
| 29 | + | } | |||
| 30 | + | ||||
| 25 | normalized := normalizeWebUIConfig(cfg) | 31 | normalized := normalizeWebUIConfig(cfg) | ||
| 26 | if err := validateWebUIConfig(normalized); err != nil { | 32 | if err := validateWebUIConfig(normalized); err != nil { | ||
| 27 | return err | 33 | return err | ||
| @@ -30,45 +36,59 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUICon | |||||
| 30 | return uc.saver.SaveWebUIConfig(ctx, normalized) | 36 | return uc.saver.SaveWebUIConfig(ctx, normalized) | ||
| 31 | } | 37 | } | ||
| 32 | 38 | ||||
| 33 | - | func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig { | 39 | + | func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error { |
| 40 | + | seen := make(map[string]string, len(shortcuts)) | |||
| 41 | + | for key := range shortcuts { | |||
| 42 | + | trimmedKey := strings.TrimSpace(key) | |||
| 43 | + | if trimmedKey == "" { | |||
| 44 | + | continue | |||
| 45 | + | } | |||
| 46 | + | if previous, ok := seen[trimmedKey]; ok && previous != key { | |||
| 47 | + | return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous) | |||
| 48 | + | } | |||
| 49 | + | seen[trimmedKey] = key | |||
| 50 | + | } | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig { | |||
| 34 | cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont) | 55 | cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont) | ||
| 35 | cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont) | 56 | cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont) | ||
| 36 | cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont) | 57 | cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont) | ||
| 37 | cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme) | 58 | cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme) | ||
| 38 | cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine) | 59 | cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine) | ||
| 39 | if len(cfg.SearchShortcuts) > 0 { | 60 | if len(cfg.SearchShortcuts) > 0 { | ||
| 40 | - | normalized := make(map[string]port.SearchShortcut, len(cfg.SearchShortcuts)) | 61 | + | normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts)) |
| 41 | for key, shortcut := range cfg.SearchShortcuts { | 62 | for key, shortcut := range cfg.SearchShortcuts { | ||
| 42 | trimmedKey := strings.TrimSpace(key) | 63 | trimmedKey := strings.TrimSpace(key) | ||
| 64 | + | if trimmedKey == "" { | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 43 | shortcut.URL = strings.TrimSpace(shortcut.URL) | 67 | shortcut.URL = strings.TrimSpace(shortcut.URL) | ||
| 44 | shortcut.Description = strings.TrimSpace(shortcut.Description) | 68 | shortcut.Description = strings.TrimSpace(shortcut.Description) | ||
| 45 | - | if trimmedKey != "" { | 69 | + | normalized[trimmedKey] = shortcut |
| 46 | - | normalized[trimmedKey] = shortcut | |||
| 47 | - | } | |||
| 48 | } | 70 | } | ||
| 49 | cfg.SearchShortcuts = normalized | 71 | cfg.SearchShortcuts = normalized | ||
| 50 | } | 72 | } | ||
| 51 | return cfg | 73 | return cfg | ||
| 52 | } | 74 | } | ||
| 53 | 75 | ||||
| 54 | - | func validateWebUIConfig(cfg port.WebUIConfig) error { | 76 | + | func validateWebUIConfig(cfg dto.WebUIConfig) error { |
| 55 | var errs []string | 77 | var errs []string | ||
| 56 | 78 | ||||
| 57 | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | 79 | if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 { | ||
| 58 | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | 80 | errs = append(errs, "appearance.default_font_size must be between 1 and 72") | ||
| 59 | } | 81 | } | ||
| 60 | - | if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { | 82 | + | if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 { |
| 61 | - | errs = append(errs, "default_ui_scale must be between 0.5 and 3.0") | 83 | + | errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0") |
| 62 | } | 84 | } | ||
| 63 | 85 | ||||
| 64 | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | 86 | errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...) | ||
| 65 | errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...) | 87 | errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...) | ||
| 66 | errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...) | 88 | errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...) | ||
| 67 | 89 | ||||
| 68 | - | if cfg.DefaultSearchEngine == "" { | 90 | + | for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) { |
| 69 | - | errs = append(errs, "default_search_engine cannot be empty") | 91 | + | errs = append(errs, fmt.Sprintf("default_search_engine: %s", err)) |
| 70 | - | } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") { | |||
| 71 | - | errs = append(errs, "default_search_engine must contain %s placeholder for the search query") | |||
| 72 | } | 92 | } | ||
| 73 | 93 | ||||
| 74 | for key, shortcut := range cfg.SearchShortcuts { | 94 | for key, shortcut := range cfg.SearchShortcuts { | ||
| @@ -83,6 +103,16 @@ func validateWebUIConfig(cfg port.WebUIConfig) error { | |||||
| 83 | } | 103 | } | ||
| 84 | } | 104 | } | ||
| 85 | 105 | ||||
| 106 | + | switch cfg.Performance.Profile { | |||
| 107 | + | case "", "default", "lite", "balanced", "max", "custom": | |||
| 108 | + | // ok | |||
| 109 | + | default: | |||
| 110 | + | errs = append(errs, fmt.Sprintf( | |||
| 111 | + | "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)", | |||
| 112 | + | cfg.Performance.Profile, | |||
| 113 | + | )) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 86 | switch cfg.Appearance.ColorScheme { | 116 | switch cfg.Appearance.ColorScheme { | ||
| 87 | case "default", "prefer-dark", "prefer-light": | 117 | case "default", "prefer-dark", "prefer-light": | ||
| 88 | // ok | 118 | // ok | ||
A internal/application/usecase/save_webui_config_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 9 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestSaveWebUIConfigUseCase_Validation(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name string | |||
| 18 | + | mutate func(*dto.WebUIConfig) | |||
| 19 | + | wantErr string | |||
| 20 | + | }{ | |||
| 21 | + | { | |||
| 22 | + | name: "rejects invalid default search URL", | |||
| 23 | + | mutate: func(cfg *dto.WebUIConfig) { | |||
| 24 | + | cfg.DefaultSearchEngine = "not-a-url-%s" | |||
| 25 | + | }, | |||
| 26 | + | wantErr: "default_search_engine", | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "rejects invalid performance profile", | |||
| 30 | + | mutate: func(cfg *dto.WebUIConfig) { | |||
| 31 | + | cfg.Performance.Profile = "turbo" | |||
| 32 | + | }, | |||
| 33 | + | wantErr: "performance.profile", | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "rejects NaN UI scale", | |||
| 37 | + | mutate: func(cfg *dto.WebUIConfig) { | |||
| 38 | + | cfg.DefaultUIScale = math.NaN() | |||
| 39 | + | }, | |||
| 40 | + | wantErr: "default_ui_scale must be a finite value", | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "rejects infinite UI scale", | |||
| 44 | + | mutate: func(cfg *dto.WebUIConfig) { | |||
| 45 | + | cfg.DefaultUIScale = math.Inf(1) | |||
| 46 | + | }, | |||
| 47 | + | wantErr: "default_ui_scale must be a finite value", | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, tt := range tests { | |||
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | + | cfg := validWebUIConfig() | |||
| 54 | + | tt.mutate(&cfg) | |||
| 55 | + | saver := portmocks.NewMockWebUIConfigSaver(t) | |||
| 56 | + | uc := usecase.NewSaveWebUIConfigUseCase(saver) | |||
| 57 | + | ||||
| 58 | + | err := uc.Execute(context.Background(), cfg) | |||
| 59 | + | ||||
| 60 | + | require.Error(t, err) | |||
| 61 | + | require.Contains(t, err.Error(), tt.wantErr) | |||
| 62 | + | }) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestSaveWebUIConfigUseCase_RejectsDuplicateTrimmedSearchShortcutKeys(t *testing.T) { | |||
| 67 | + | saver := portmocks.NewMockWebUIConfigSaver(t) | |||
| 68 | + | uc := usecase.NewSaveWebUIConfigUseCase(saver) | |||
| 69 | + | cfg := validWebUIConfig() | |||
| 70 | + | cfg.SearchShortcuts["ddg"] = dto.SearchShortcut{URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"} | |||
| 71 | + | cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: "https://example.com/?q=%s", Description: "Duplicate"} | |||
| 72 | + | ||||
| 73 | + | err := uc.Execute(context.Background(), cfg) | |||
| 74 | + | ||||
| 75 | + | require.Error(t, err) | |||
| 76 | + | require.Contains(t, err.Error(), "duplicates") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | |||
| 80 | + | saver := portmocks.NewMockWebUIConfigSaver(t) | |||
| 81 | + | var saved dto.WebUIConfig | |||
| 82 | + | saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("dto.WebUIConfig")).Run(func(_ context.Context, cfg dto.WebUIConfig) { | |||
| 83 | + | saved = cfg | |||
| 84 | + | }).Return(nil).Once() | |||
| 85 | + | uc := usecase.NewSaveWebUIConfigUseCase(saver) | |||
| 86 | + | cfg := validWebUIConfig() | |||
| 87 | + | cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "} | |||
| 88 | + | cfg.SearchShortcuts[" "] = dto.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"} | |||
| 89 | + | ||||
| 90 | + | err := uc.Execute(context.Background(), cfg) | |||
| 91 | + | ||||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | require.NotContains(t, saved.SearchShortcuts, " ddg ") | |||
| 94 | + | require.NotContains(t, saved.SearchShortcuts, "") | |||
| 95 | + | require.Contains(t, saved.SearchShortcuts, "ddg") | |||
| 96 | + | require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL) | |||
| 97 | + | require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func validWebUIConfig() dto.WebUIConfig { | |||
| 101 | + | palette := dto.ColorPalette{ | |||
| 102 | + | Background: "#ffffff", | |||
| 103 | + | Surface: "#f8f8f8", | |||
| 104 | + | SurfaceVariant: "#eeeeee", | |||
| 105 | + | Text: "#111111", | |||
| 106 | + | Muted: "#666666", | |||
| 107 | + | Accent: "#0055ff", | |||
| 108 | + | Border: "#dddddd", | |||
| 109 | + | } | |||
| 110 | + | return dto.WebUIConfig{ | |||
| 111 | + | Appearance: dto.WebUIAppearanceConfig{ | |||
| 112 | + | SansFont: "Inter", | |||
| 113 | + | SerifFont: "Georgia", | |||
| 114 | + | MonospaceFont: "JetBrains Mono", | |||
| 115 | + | DefaultFontSize: 16, | |||
| 116 | + | ColorScheme: "prefer-dark", | |||
| 117 | + | LightPalette: palette, | |||
| 118 | + | DarkPalette: palette, | |||
| 119 | + | }, | |||
| 120 | + | Performance: dto.WebUIPerformanceConfig{ | |||
| 121 | + | Profile: "default", | |||
| 122 | + | }, | |||
| 123 | + | DefaultUIScale: 1, | |||
| 124 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |||
| 125 | + | SearchShortcuts: map[string]dto.SearchShortcut{}, | |||
| 126 | + | } | |||
| 127 | + | } | |||
M internal/application/usecase/search_history.go +116 -7
| @@ -3,17 +3,23 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "strings" | |||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | + | historydomain "github.com/bnema/dumber/internal/domain/history" | |||
| 10 | "github.com/bnema/dumber/internal/domain/repository" | 13 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 14 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 11 | "github.com/bnema/dumber/internal/logging" | 15 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | ) | 16 | ) | ||
| 13 | 17 | ||||
| 14 | // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory. | 18 | // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory. | ||
| 15 | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | 19 | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | ||
| 16 | 20 | ||||
| 21 | + | const historyWindowDuration = 24 * time.Hour | |||
| 22 | + | ||||
| 17 | // SearchHistoryUseCase handles history search and retrieval operations. | 23 | // SearchHistoryUseCase handles history search and retrieval operations. | ||
| 18 | type SearchHistoryUseCase struct { | 24 | type SearchHistoryUseCase struct { | ||
| 19 | historyRepo repository.HistoryRepository | 25 | historyRepo repository.HistoryRepository | ||
| @@ -26,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi | |||||
| 26 | } | 32 | } | ||
| 27 | } | 33 | } | ||
| 28 | 34 | ||||
| 29 | - | // SearchInput is an alias for port.HistorySearchInput. | 35 | + | // SearchInput is an alias for dto.HistorySearchInput. |
| 30 | - | type SearchInput = port.HistorySearchInput | 36 | + | type SearchInput = dto.HistorySearchInput |
| 31 | 37 | ||||
| 32 | - | // SearchOutput is an alias for port.HistorySearchOutput. | 38 | + | // SearchOutput is an alias for dto.HistorySearchOutput. |
| 33 | - | type SearchOutput = port.HistorySearchOutput | 39 | + | type SearchOutput = dto.HistorySearchOutput |
| 34 | 40 | ||||
| 35 | // Search performs a full-text search on history entries using SQLite FTS5. | 41 | // Search performs a full-text search on history entries using SQLite FTS5. | ||
| 36 | // Returns only entries that actually match the query terms. | 42 | // Returns only entries that actually match the query terms. | ||
| @@ -60,10 +66,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||||
| 60 | return &SearchOutput{Matches: matches}, nil | 66 | return &SearchOutput{Matches: matches}, nil | ||
| 61 | } | 67 | } | ||
| 62 | 68 | ||||
| 63 | - | // GetRecent retrieves recent history entries with pagination. | 69 | + | // GetRecent retrieves recent history entries. A zero limit means all entries; |
| 70 | + | // negative limits retain the historical default page size. | |||
| 64 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 71 | func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 65 | - | if limit <= 0 { | 72 | + | if limit < 0 { |
| 66 | - | limit = 50 // Default limit | 73 | + | limit = 50 // Default limit for invalid negative values. |
| 67 | } | 74 | } | ||
| 68 | 75 | ||||
| 69 | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | 76 | entries, err := uc.historyRepo.GetRecent(ctx, limit, offset) | ||
| @@ -74,6 +81,75 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||||
| 74 | return entries, nil | 81 | return entries, nil | ||
| 75 | } | 82 | } | ||
| 76 | 83 | ||||
| 84 | + | // GetRecentByDomain retrieves recent history entries for a canonical domain. A | |||
| 85 | + | // zero limit means all matching entries; negative limits use the default page size. | |||
| 86 | + | func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |||
| 87 | + | if limit < 0 { | |||
| 88 | + | limit = 50 | |||
| 89 | + | } | |||
| 90 | + | domain, err := canonicalHistoryDomain(domain) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return nil, err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return nil, fmt.Errorf("failed to get recent history by domain: %w", err) | |||
| 98 | + | } | |||
| 99 | + | return entries, nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // canonicalHistoryDomain normalizes a domain and rejects empty values before | |||
| 103 | + | // the domain reaches exact-match persistence queries. | |||
| 104 | + | func canonicalHistoryDomain(domain string) (string, error) { | |||
| 105 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 106 | + | if domain == "" { | |||
| 107 | + | return "", fmt.Errorf("domain is required") | |||
| 108 | + | } | |||
| 109 | + | return domain, nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // GetRecentWindow retrieves the 24-hour history window ending before the given cursor. | |||
| 113 | + | func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 114 | + | if before.IsZero() { | |||
| 115 | + | before = time.Now() | |||
| 116 | + | } | |||
| 117 | + | before = before.UTC() | |||
| 118 | + | after := before.Add(-historyWindowDuration) | |||
| 119 | + | ||||
| 120 | + | var ( | |||
| 121 | + | entries []*entity.HistoryEntry | |||
| 122 | + | hasMore bool | |||
| 123 | + | err error | |||
| 124 | + | ) | |||
| 125 | + | originalDomain := domain | |||
| 126 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 127 | + | if strings.TrimSpace(originalDomain) != "" && domain == "" { | |||
| 128 | + | return nil, fmt.Errorf("domain is required") | |||
| 129 | + | } | |||
| 130 | + | if domain != "" { | |||
| 131 | + | entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after) | |||
| 132 | + | if err == nil { | |||
| 133 | + | hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after) | |||
| 134 | + | } | |||
| 135 | + | } else { | |||
| 136 | + | entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after) | |||
| 137 | + | if err == nil { | |||
| 138 | + | hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | if err != nil { | |||
| 142 | + | return nil, fmt.Errorf("failed to get recent history window: %w", err) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | return &entity.HistoryWindow{ | |||
| 146 | + | Entries: entries, | |||
| 147 | + | Before: before, | |||
| 148 | + | After: after, | |||
| 149 | + | HasMore: hasMore, | |||
| 150 | + | }, nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 77 | // GetRecentSince retrieves history entries visited within the last N days. | 153 | // GetRecentSince retrieves history entries visited within the last N days. | ||
| 78 | // If days is 0, returns all history entries. | 154 | // If days is 0, returns all history entries. | ||
| 79 | // If days is negative, defaults to 30 days. | 155 | // If days is negative, defaults to 30 days. | ||
| @@ -154,6 +230,25 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time. | |||||
| 154 | return nil | 230 | return nil | ||
| 155 | } | 231 | } | ||
| 156 | 232 | ||||
| 233 | + | // ClearRange deletes history entries inside a named recent range. | |||
| 234 | + | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | |||
| 235 | + | cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | |||
| 236 | + | if !ok { | |||
| 237 | + | return fmt.Errorf("unknown history delete range: %q", rangeID) | |||
| 238 | + | } | |||
| 239 | + | if all { | |||
| 240 | + | return uc.ClearAll(ctx) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | log := logging.FromContext(ctx) | |||
| 244 | + | log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range") | |||
| 245 | + | if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil { | |||
| 246 | + | return fmt.Errorf("failed to clear history range: %w", err) | |||
| 247 | + | } | |||
| 248 | + | log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared") | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 157 | // ClearAll deletes all history entries. | 252 | // ClearAll deletes all history entries. | ||
| 158 | func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error { | 253 | func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error { | ||
| 159 | log := logging.FromContext(ctx) | 254 | log := logging.FromContext(ctx) | ||
| @@ -182,6 +277,11 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error { | |||||
| 182 | 277 | ||||
| 183 | // DeleteByDomain removes all history entries for a domain. | 278 | // DeleteByDomain removes all history entries for a domain. | ||
| 184 | func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error { | 279 | func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error { | ||
| 280 | + | domain, err := canonicalHistoryDomain(domain) | |||
| 281 | + | if err != nil { | |||
| 282 | + | return err | |||
| 283 | + | } | |||
| 284 | + | ||||
| 185 | log := logging.FromContext(ctx) | 285 | log := logging.FromContext(ctx) | ||
| 186 | log.Debug().Str("domain", domain).Msg("deleting history by domain") | 286 | log.Debug().Str("domain", domain).Msg("deleting history by domain") | ||
| 187 | 287 | ||||
| @@ -205,6 +305,15 @@ func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) ( | |||||
| 205 | return uc.historyRepo.GetDomainStats(ctx, limit) | 305 | return uc.historyRepo.GetDomainStats(ctx, limit) | ||
| 206 | } | 306 | } | ||
| 207 | 307 | ||||
| 308 | + | // GetStats retrieves lightweight aggregate history statistics. | |||
| 309 | + | func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |||
| 310 | + | stats, err := uc.historyRepo.GetStats(ctx) | |||
| 311 | + | if err != nil { | |||
| 312 | + | return nil, fmt.Errorf("failed to get history stats: %w", err) | |||
| 313 | + | } | |||
| 314 | + | return stats, nil | |||
| 315 | + | } | |||
| 316 | + | ||||
| 208 | // GetAnalytics retrieves aggregated history analytics for the homepage. | 317 | // GetAnalytics retrieves aggregated history analytics for the homepage. | ||
| 209 | func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | 318 | func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | ||
| 210 | log := logging.FromContext(ctx) | 319 | log := logging.FromContext(ctx) | ||
M internal/application/usecase/search_history_test.go +116 -0
| @@ -13,6 +13,43 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) { | |||
| 17 | + | ctx := testContext() | |||
| 18 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 19 | + | entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}} | |||
| 20 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once() | |||
| 21 | + | ||||
| 22 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 23 | + | result, err := uc.GetRecent(ctx, 0, 0) | |||
| 24 | + | ||||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | require.Len(t, result, 1) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) { | |||
| 30 | + | ctx := testContext() | |||
| 31 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 32 | + | historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once() | |||
| 33 | + | ||||
| 34 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 35 | + | result, err := uc.GetRecent(ctx, -1, 0) | |||
| 36 | + | ||||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | assert.Empty(t, result) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) { | |||
| 42 | + | ctx := testContext() | |||
| 43 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 44 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 45 | + | ||||
| 46 | + | result, err := uc.GetRecentWindow(ctx, time.Now(), "///") | |||
| 47 | + | ||||
| 48 | + | require.Error(t, err) | |||
| 49 | + | assert.Nil(t, result) | |||
| 50 | + | assert.Contains(t, err.Error(), "domain is required") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 16 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | 53 | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | ||
| 17 | ctx := testContext() | 54 | ctx := testContext() | ||
| 18 | 55 | ||||
| @@ -151,3 +188,82 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin | |||||
| 151 | require.NotNil(t, result) | 188 | require.NotNil(t, result) | ||
| 152 | assert.Empty(t, result) | 189 | assert.Empty(t, result) | ||
| 153 | } | 190 | } | ||
| 191 | + | ||||
| 192 | + | func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) { | |||
| 193 | + | ctx := testContext() | |||
| 194 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 195 | + | now := time.Now() | |||
| 196 | + | expectedCutoff := now.Add(-time.Hour) | |||
| 197 | + | historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool { | |||
| 198 | + | return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute)) | |||
| 199 | + | })).Return(nil).Once() | |||
| 200 | + | ||||
| 201 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 202 | + | ||||
| 203 | + | err := uc.ClearRange(ctx, "hour") | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) { | |||
| 208 | + | ctx := testContext() | |||
| 209 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 210 | + | now := time.Now() | |||
| 211 | + | expectedCutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) | |||
| 212 | + | historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool { | |||
| 213 | + | return since.Equal(expectedCutoff) | |||
| 214 | + | })).Return(nil).Once() | |||
| 215 | + | ||||
| 216 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 217 | + | ||||
| 218 | + | err := uc.ClearRange(ctx, "day") | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) { | |||
| 223 | + | ctx := testContext() | |||
| 224 | + | ||||
| 225 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 226 | + | ||||
| 227 | + | historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil) | |||
| 228 | + | ||||
| 229 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 230 | + | ||||
| 231 | + | err := uc.ClearRange(ctx, "all") | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) { | |||
| 236 | + | ctx := testContext() | |||
| 237 | + | ||||
| 238 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 239 | + | ||||
| 240 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 241 | + | ||||
| 242 | + | err := uc.ClearRange(ctx, "bogus") | |||
| 243 | + | require.Error(t, err) | |||
| 244 | + | assert.Contains(t, err.Error(), "unknown history delete range") | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) { | |||
| 248 | + | ctx := testContext() | |||
| 249 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 250 | + | want := []*entity.HistoryEntry{{URL: "https://example_.com"}} | |||
| 251 | + | historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once() | |||
| 252 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 253 | + | ||||
| 254 | + | result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0) | |||
| 255 | + | ||||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | assert.Equal(t, want, result) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) { | |||
| 261 | + | ctx := testContext() | |||
| 262 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 263 | + | historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once() | |||
| 264 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 265 | + | ||||
| 266 | + | err := uc.DeleteByDomain(ctx, "example_.com") | |||
| 267 | + | ||||
| 268 | + | require.NoError(t, err) | |||
| 269 | + | } | |||
M internal/bootstrap/engine.go +17 -14
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 10 | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | 11 | audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory" | ||
| @@ -33,6 +34,19 @@ type EngineInput struct { | |||||
| 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | 34 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | ||
| 34 | func BuildEngine(input EngineInput) (port.Engine, error) { | 35 | func BuildEngine(input EngineInput) (port.Engine, error) { | ||
| 35 | cfg := input.Config | 36 | cfg := input.Config | ||
| 37 | + | systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor()) | |||
| 38 | + | systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader) | |||
| 39 | + | buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) { | |||
| 40 | + | return func() ([]byte, error) { | |||
| 41 | + | payload, err := read(input.Ctx) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 45 | + | return json.Marshal(payload) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | currentConfigPayload := buildConfigPayload(systemviewUC.Current) | |||
| 49 | + | defaultConfigPayload := buildConfigPayload(systemviewUC.Default) | |||
| 36 | contextMenuBuilder := usecase.NewBuildContextMenuUseCase() | 50 | contextMenuBuilder := usecase.NewBuildContextMenuUseCase() | ||
| 37 | contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{} | 51 | contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{} | ||
| 38 | engineType := cfg.Engine.ResolveEngineType() | 52 | engineType := cfg.Engine.ResolveEngineType() | ||
| @@ -49,6 +63,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 49 | 63 | ||||
| 50 | return webkit.NewEngine( | 64 | return webkit.NewEngine( | ||
| 51 | input.Ctx, cfg, opts, profile, wkCfg, | 65 | input.Ctx, cfg, opts, profile, wkCfg, | ||
| 66 | + | currentConfigPayload, defaultConfigPayload, | |||
| 52 | input.ThemeManager, input.ColorResolver, | 67 | input.ThemeManager, input.ColorResolver, | ||
| 53 | contextMenuBuilder, contextMenuExecutorFactory, | 68 | contextMenuBuilder, contextMenuExecutorFactory, | ||
| 54 | input.Logger, | 69 | input.Logger, | ||
| @@ -75,23 +90,11 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 75 | MaxConcurrent: cfg.Transcoding.MaxConcurrent, | 90 | MaxConcurrent: cfg.Transcoding.MaxConcurrent, | ||
| 76 | Quality: cfg.Transcoding.Quality, | 91 | Quality: cfg.Transcoding.Quality, | ||
| 77 | } | 92 | } | ||
| 78 | - | surveyor := env.NewHardwareSurveyor() | |||
| 79 | - | buildConfigPayload := func(cfgf func() *config.Config) func() ([]byte, error) { | |||
| 80 | - | return func() ([]byte, error) { | |||
| 81 | - | cfg := cfgf() | |||
| 82 | - | var hw *port.HardwareInfo | |||
| 83 | - | if surveyor != nil { | |||
| 84 | - | survey := surveyor.Survey(context.Background()) | |||
| 85 | - | hw = &survey | |||
| 86 | - | } | |||
| 87 | - | return json.Marshal(config.BuildWebUIConfigPayload(cfg, hw)) | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | deps := cef.EngineDependencies{ | 93 | deps := cef.EngineDependencies{ | ||
| 91 | RegisterHandlers: handlers.RegisterAll, | 94 | RegisterHandlers: handlers.RegisterAll, | ||
| 92 | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | 95 | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | ||
| 93 | - | CurrentConfigPayload: buildConfigPayload(config.Get), | 96 | + | CurrentConfigPayload: currentConfigPayload, |
| 94 | - | DefaultConfigPayload: buildConfigPayload(config.DefaultConfig), | 97 | + | DefaultConfigPayload: defaultConfigPayload, |
| 95 | ContextMenuBuilder: contextMenuBuilder, | 98 | ContextMenuBuilder: contextMenuBuilder, | ||
| 96 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | 99 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | ||
| 97 | Clipboard: clipboardinfra.New(), | 100 | Clipboard: clipboardinfra.New(), | ||
A internal/domain/entity/history_window.go +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // HistoryWindow contains one fixed-duration slice of browsing history. | |||
| 6 | + | type HistoryWindow struct { | |||
| 7 | + | Entries []*HistoryEntry `json:"entries"` | |||
| 8 | + | Before time.Time `json:"before"` | |||
| 9 | + | After time.Time `json:"after"` | |||
| 10 | + | HasMore bool `json:"has_more"` | |||
| 11 | + | } | |||
A internal/domain/history/delete_range.go +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package history | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time. | |||
| 6 | + | // The booleans report whether the range means "delete all" and whether the | |||
| 7 | + | // identifier was recognized. | |||
| 8 | + | func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) { | |||
| 9 | + | switch rangeID { | |||
| 10 | + | case "hour": | |||
| 11 | + | return now.Add(-time.Hour), false, true | |||
| 12 | + | case "day": | |||
| 13 | + | return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), false, true | |||
| 14 | + | case "week": | |||
| 15 | + | return now.AddDate(0, 0, -7), false, true | |||
| 16 | + | case "month": | |||
| 17 | + | return now.AddDate(0, -1, 0), false, true | |||
| 18 | + | case "all": | |||
| 19 | + | return time.Time{}, true, true | |||
| 20 | + | default: | |||
| 21 | + | return time.Time{}, false, false | |||
| 22 | + | } | |||
| 23 | + | } | |||
A internal/domain/history/delete_range_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package history | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestDeleteRangeCutoff(t *testing.T) { | |||
| 9 | + | now := time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC) | |||
| 10 | + | ||||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | rangeID string | |||
| 14 | + | want time.Time | |||
| 15 | + | wantAll bool | |||
| 16 | + | wantOK bool | |||
| 17 | + | }{ | |||
| 18 | + | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true}, | |||
| 19 | + | {name: "day", rangeID: "day", want: time.Date(2026, time.April, 11, 0, 0, 0, 0, time.UTC), wantAll: false, wantOK: true}, | |||
| 20 | + | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true}, | |||
| 21 | + | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true}, | |||
| 22 | + | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true}, | |||
| 23 | + | {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false}, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | for _, tt := range tests { | |||
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 28 | + | got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now) | |||
| 29 | + | if !got.Equal(tt.want) { | |||
| 30 | + | t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want) | |||
| 31 | + | } | |||
| 32 | + | if gotAll != tt.wantAll { | |||
| 33 | + | t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll) | |||
| 34 | + | } | |||
| 35 | + | if gotOK != tt.wantOK { | |||
| 36 | + | t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK) | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
M internal/domain/repository/history.go +19 -1
| @@ -18,9 +18,24 @@ type HistoryRepository interface { | |||||
| 18 | // Search performs a fuzzy search on history entries. | 18 | // Search performs a fuzzy search on history entries. | ||
| 19 | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | 19 | Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) | ||
| 20 | 20 | ||||
| 21 | - | // GetRecent retrieves recent history entries with pagination. | 21 | + | // GetRecent retrieves recent history entries with pagination. A zero limit means all entries. |
| 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 23 | 23 | ||||
| 24 | + | // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries. | |||
| 25 | + | GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) | |||
| 26 | + | ||||
| 27 | + | // GetRecentWindow retrieves entries visited in [after, before), sorted by recency. | |||
| 28 | + | GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) | |||
| 29 | + | ||||
| 30 | + | // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency. | |||
| 31 | + | GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) | |||
| 32 | + | ||||
| 33 | + | // HasEntriesBefore reports whether any history entry was visited before the given time. | |||
| 34 | + | HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) | |||
| 35 | + | ||||
| 36 | + | // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time. | |||
| 37 | + | HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) | |||
| 38 | + | ||||
| 24 | // GetRecentSince retrieves history entries visited within the last N days. | 39 | // GetRecentSince retrieves history entries visited within the last N days. | ||
| 25 | // days must be > 0. | 40 | // days must be > 0. | ||
| 26 | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | 41 | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | ||
| @@ -44,6 +59,9 @@ type HistoryRepository interface { | |||||
| 44 | // DeleteOlderThan removes entries older than the given time. | 59 | // DeleteOlderThan removes entries older than the given time. | ||
| 45 | DeleteOlderThan(ctx context.Context, before time.Time) error | 60 | DeleteOlderThan(ctx context.Context, before time.Time) error | ||
| 46 | 61 | ||||
| 62 | + | // DeleteSince removes entries visited at or after the given time. | |||
| 63 | + | DeleteSince(ctx context.Context, since time.Time) error | |||
| 64 | + | ||||
| 47 | // DeleteAll removes all history entries. | 65 | // DeleteAll removes all history entries. | ||
| 48 | DeleteAll(ctx context.Context) error | 66 | DeleteAll(ctx context.Context) error | ||
| 49 | 67 | ||||
M internal/domain/repository/mocks/mocks.go +429 -0
| @@ -2129,6 +2129,63 @@ func (_c *MockHistoryRepository_DeleteOlderThan_Call) RunAndReturn(run func(ctx | |||||
| 2129 | return _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) | ||
| @@ -2661,6 +2718,86 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex | |||||
| 2661 | return _c | 2718 | return _c | ||
| 2662 | } | 2719 | } | ||
| 2663 | 2720 | ||||
| 2721 | + | // GetRecentByDomain provides a mock function for the type MockHistoryRepository | |||
| 2722 | + | func (_mock *MockHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) { | |||
| 2723 | + | ret := _mock.Called(ctx, domain, limit, offset) | |||
| 2724 | + | ||||
| 2725 | + | if len(ret) == 0 { | |||
| 2726 | + | panic("no return value specified for GetRecentByDomain") | |||
| 2727 | + | } | |||
| 2728 | + | ||||
| 2729 | + | var r0 []*entity.HistoryEntry | |||
| 2730 | + | var r1 error | |||
| 2731 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok { | |||
| 2732 | + | return returnFunc(ctx, domain, limit, offset) | |||
| 2733 | + | } | |||
| 2734 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok { | |||
| 2735 | + | r0 = returnFunc(ctx, domain, limit, offset) | |||
| 2736 | + | } else { | |||
| 2737 | + | if ret.Get(0) != nil { | |||
| 2738 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2739 | + | } | |||
| 2740 | + | } | |||
| 2741 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok { | |||
| 2742 | + | r1 = returnFunc(ctx, domain, limit, offset) | |||
| 2743 | + | } else { | |||
| 2744 | + | r1 = ret.Error(1) | |||
| 2745 | + | } | |||
| 2746 | + | return r0, r1 | |||
| 2747 | + | } | |||
| 2748 | + | ||||
| 2749 | + | // MockHistoryRepository_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain' | |||
| 2750 | + | type MockHistoryRepository_GetRecentByDomain_Call struct { | |||
| 2751 | + | *mock.Call | |||
| 2752 | + | } | |||
| 2753 | + | ||||
| 2754 | + | // GetRecentByDomain is a helper method to define mock.On call | |||
| 2755 | + | // - ctx context.Context | |||
| 2756 | + | // - domain string | |||
| 2757 | + | // - limit int | |||
| 2758 | + | // - offset int | |||
| 2759 | + | func (_e *MockHistoryRepository_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHistoryRepository_GetRecentByDomain_Call { | |||
| 2760 | + | return &MockHistoryRepository_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)} | |||
| 2761 | + | } | |||
| 2762 | + | ||||
| 2763 | + | func (_c *MockHistoryRepository_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHistoryRepository_GetRecentByDomain_Call { | |||
| 2764 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2765 | + | var arg0 context.Context | |||
| 2766 | + | if args[0] != nil { | |||
| 2767 | + | arg0 = args[0].(context.Context) | |||
| 2768 | + | } | |||
| 2769 | + | var arg1 string | |||
| 2770 | + | if args[1] != nil { | |||
| 2771 | + | arg1 = args[1].(string) | |||
| 2772 | + | } | |||
| 2773 | + | var arg2 int | |||
| 2774 | + | if args[2] != nil { | |||
| 2775 | + | arg2 = args[2].(int) | |||
| 2776 | + | } | |||
| 2777 | + | var arg3 int | |||
| 2778 | + | if args[3] != nil { | |||
| 2779 | + | arg3 = args[3].(int) | |||
| 2780 | + | } | |||
| 2781 | + | run( | |||
| 2782 | + | arg0, | |||
| 2783 | + | arg1, | |||
| 2784 | + | arg2, | |||
| 2785 | + | arg3, | |||
| 2786 | + | ) | |||
| 2787 | + | }) | |||
| 2788 | + | return _c | |||
| 2789 | + | } | |||
| 2790 | + | ||||
| 2791 | + | func (_c *MockHistoryRepository_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentByDomain_Call { | |||
| 2792 | + | _c.Call.Return(historyEntrys, err) | |||
| 2793 | + | return _c | |||
| 2794 | + | } | |||
| 2795 | + | ||||
| 2796 | + | func (_c *MockHistoryRepository_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentByDomain_Call { | |||
| 2797 | + | _c.Call.Return(run) | |||
| 2798 | + | return _c | |||
| 2799 | + | } | |||
| 2800 | + | ||||
| 2664 | // GetRecentSince provides a mock function for the type MockHistoryRepository | 2801 | // GetRecentSince provides a mock function for the type MockHistoryRepository | ||
| 2665 | func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 2802 | func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| 2666 | ret := _mock.Called(ctx, days) | 2803 | ret := _mock.Called(ctx, days) | ||
| @@ -2729,6 +2866,160 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c | |||||
| 2729 | return _c | 2866 | return _c | ||
| 2730 | } | 2867 | } | ||
| 2731 | 2868 | ||||
| 2869 | + | // GetRecentWindow provides a mock function for the type MockHistoryRepository | |||
| 2870 | + | func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 2871 | + | ret := _mock.Called(ctx, before, after) | |||
| 2872 | + | ||||
| 2873 | + | if len(ret) == 0 { | |||
| 2874 | + | panic("no return value specified for GetRecentWindow") | |||
| 2875 | + | } | |||
| 2876 | + | ||||
| 2877 | + | var r0 []*entity.HistoryEntry | |||
| 2878 | + | var r1 error | |||
| 2879 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |||
| 2880 | + | return returnFunc(ctx, before, after) | |||
| 2881 | + | } | |||
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok { | |||
| 2883 | + | r0 = returnFunc(ctx, before, after) | |||
| 2884 | + | } else { | |||
| 2885 | + | if ret.Get(0) != nil { | |||
| 2886 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2887 | + | } | |||
| 2888 | + | } | |||
| 2889 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok { | |||
| 2890 | + | r1 = returnFunc(ctx, before, after) | |||
| 2891 | + | } else { | |||
| 2892 | + | r1 = ret.Error(1) | |||
| 2893 | + | } | |||
| 2894 | + | return r0, r1 | |||
| 2895 | + | } | |||
| 2896 | + | ||||
| 2897 | + | // MockHistoryRepository_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow' | |||
| 2898 | + | type MockHistoryRepository_GetRecentWindow_Call struct { | |||
| 2899 | + | *mock.Call | |||
| 2900 | + | } | |||
| 2901 | + | ||||
| 2902 | + | // GetRecentWindow is a helper method to define mock.On call | |||
| 2903 | + | // - ctx context.Context | |||
| 2904 | + | // - before time.Time | |||
| 2905 | + | // - after time.Time | |||
| 2906 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2907 | + | return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)} | |||
| 2908 | + | } | |||
| 2909 | + | ||||
| 2910 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2912 | + | var arg0 context.Context | |||
| 2913 | + | if args[0] != nil { | |||
| 2914 | + | arg0 = args[0].(context.Context) | |||
| 2915 | + | } | |||
| 2916 | + | var arg1 time.Time | |||
| 2917 | + | if args[1] != nil { | |||
| 2918 | + | arg1 = args[1].(time.Time) | |||
| 2919 | + | } | |||
| 2920 | + | var arg2 time.Time | |||
| 2921 | + | if args[2] != nil { | |||
| 2922 | + | arg2 = args[2].(time.Time) | |||
| 2923 | + | } | |||
| 2924 | + | run( | |||
| 2925 | + | arg0, | |||
| 2926 | + | arg1, | |||
| 2927 | + | arg2, | |||
| 2928 | + | ) | |||
| 2929 | + | }) | |||
| 2930 | + | return _c | |||
| 2931 | + | } | |||
| 2932 | + | ||||
| 2933 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2934 | + | _c.Call.Return(historyEntrys, err) | |||
| 2935 | + | return _c | |||
| 2936 | + | } | |||
| 2937 | + | ||||
| 2938 | + | func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call { | |||
| 2939 | + | _c.Call.Return(run) | |||
| 2940 | + | return _c | |||
| 2941 | + | } | |||
| 2942 | + | ||||
| 2943 | + | // GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository | |||
| 2944 | + | func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 2945 | + | ret := _mock.Called(ctx, domain, before, after) | |||
| 2946 | + | ||||
| 2947 | + | if len(ret) == 0 { | |||
| 2948 | + | panic("no return value specified for GetRecentWindowByDomain") | |||
| 2949 | + | } | |||
| 2950 | + | ||||
| 2951 | + | var r0 []*entity.HistoryEntry | |||
| 2952 | + | var r1 error | |||
| 2953 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok { | |||
| 2954 | + | return returnFunc(ctx, domain, before, after) | |||
| 2955 | + | } | |||
| 2956 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok { | |||
| 2957 | + | r0 = returnFunc(ctx, domain, before, after) | |||
| 2958 | + | } else { | |||
| 2959 | + | if ret.Get(0) != nil { | |||
| 2960 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2961 | + | } | |||
| 2962 | + | } | |||
| 2963 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok { | |||
| 2964 | + | r1 = returnFunc(ctx, domain, before, after) | |||
| 2965 | + | } else { | |||
| 2966 | + | r1 = ret.Error(1) | |||
| 2967 | + | } | |||
| 2968 | + | return r0, r1 | |||
| 2969 | + | } | |||
| 2970 | + | ||||
| 2971 | + | // MockHistoryRepository_GetRecentWindowByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindowByDomain' | |||
| 2972 | + | type MockHistoryRepository_GetRecentWindowByDomain_Call struct { | |||
| 2973 | + | *mock.Call | |||
| 2974 | + | } | |||
| 2975 | + | ||||
| 2976 | + | // GetRecentWindowByDomain is a helper method to define mock.On call | |||
| 2977 | + | // - ctx context.Context | |||
| 2978 | + | // - domain string | |||
| 2979 | + | // - before time.Time | |||
| 2980 | + | // - after time.Time | |||
| 2981 | + | func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx interface{}, domain interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 2982 | + | return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)} | |||
| 2983 | + | } | |||
| 2984 | + | ||||
| 2985 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 2986 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2987 | + | var arg0 context.Context | |||
| 2988 | + | if args[0] != nil { | |||
| 2989 | + | arg0 = args[0].(context.Context) | |||
| 2990 | + | } | |||
| 2991 | + | var arg1 string | |||
| 2992 | + | if args[1] != nil { | |||
| 2993 | + | arg1 = args[1].(string) | |||
| 2994 | + | } | |||
| 2995 | + | var arg2 time.Time | |||
| 2996 | + | if args[2] != nil { | |||
| 2997 | + | arg2 = args[2].(time.Time) | |||
| 2998 | + | } | |||
| 2999 | + | var arg3 time.Time | |||
| 3000 | + | if args[3] != nil { | |||
| 3001 | + | arg3 = args[3].(time.Time) | |||
| 3002 | + | } | |||
| 3003 | + | run( | |||
| 3004 | + | arg0, | |||
| 3005 | + | arg1, | |||
| 3006 | + | arg2, | |||
| 3007 | + | arg3, | |||
| 3008 | + | ) | |||
| 3009 | + | }) | |||
| 3010 | + | return _c | |||
| 3011 | + | } | |||
| 3012 | + | ||||
| 3013 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 3014 | + | _c.Call.Return(historyEntrys, err) | |||
| 3015 | + | return _c | |||
| 3016 | + | } | |||
| 3017 | + | ||||
| 3018 | + | func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call { | |||
| 3019 | + | _c.Call.Return(run) | |||
| 3020 | + | return _c | |||
| 3021 | + | } | |||
| 3022 | + | ||||
| 2732 | // GetStats provides a mock function for the type MockHistoryRepository | 3023 | // GetStats provides a mock function for the type MockHistoryRepository | ||
| 2733 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | 3024 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | ||
| 2734 | ret := _mock.Called(ctx) | 3025 | ret := _mock.Called(ctx) | ||
| @@ -2791,6 +3082,144 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context | |||||
| 2791 | return _c | 3082 | return _c | ||
| 2792 | } | 3083 | } | ||
| 2793 | 3084 | ||||
| 3085 | + | // HasEntriesBefore provides a mock function for the type MockHistoryRepository | |||
| 3086 | + | func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 3087 | + | ret := _mock.Called(ctx, before) | |||
| 3088 | + | ||||
| 3089 | + | if len(ret) == 0 { | |||
| 3090 | + | panic("no return value specified for HasEntriesBefore") | |||
| 3091 | + | } | |||
| 3092 | + | ||||
| 3093 | + | var r0 bool | |||
| 3094 | + | var r1 error | |||
| 3095 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok { | |||
| 3096 | + | return returnFunc(ctx, before) | |||
| 3097 | + | } | |||
| 3098 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok { | |||
| 3099 | + | r0 = returnFunc(ctx, before) | |||
| 3100 | + | } else { | |||
| 3101 | + | r0 = ret.Get(0).(bool) | |||
| 3102 | + | } | |||
| 3103 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |||
| 3104 | + | r1 = returnFunc(ctx, before) | |||
| 3105 | + | } else { | |||
| 3106 | + | r1 = ret.Error(1) | |||
| 3107 | + | } | |||
| 3108 | + | return r0, r1 | |||
| 3109 | + | } | |||
| 3110 | + | ||||
| 3111 | + | // MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore' | |||
| 3112 | + | type MockHistoryRepository_HasEntriesBefore_Call struct { | |||
| 3113 | + | *mock.Call | |||
| 3114 | + | } | |||
| 3115 | + | ||||
| 3116 | + | // HasEntriesBefore is a helper method to define mock.On call | |||
| 3117 | + | // - ctx context.Context | |||
| 3118 | + | // - before time.Time | |||
| 3119 | + | func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx interface{}, before interface{}) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3120 | + | return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)} | |||
| 3121 | + | } | |||
| 3122 | + | ||||
| 3123 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3124 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3125 | + | var arg0 context.Context | |||
| 3126 | + | if args[0] != nil { | |||
| 3127 | + | arg0 = args[0].(context.Context) | |||
| 3128 | + | } | |||
| 3129 | + | var arg1 time.Time | |||
| 3130 | + | if args[1] != nil { | |||
| 3131 | + | arg1 = args[1].(time.Time) | |||
| 3132 | + | } | |||
| 3133 | + | run( | |||
| 3134 | + | arg0, | |||
| 3135 | + | arg1, | |||
| 3136 | + | ) | |||
| 3137 | + | }) | |||
| 3138 | + | return _c | |||
| 3139 | + | } | |||
| 3140 | + | ||||
| 3141 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3142 | + | _c.Call.Return(b, err) | |||
| 3143 | + | return _c | |||
| 3144 | + | } | |||
| 3145 | + | ||||
| 3146 | + | func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call { | |||
| 3147 | + | _c.Call.Return(run) | |||
| 3148 | + | return _c | |||
| 3149 | + | } | |||
| 3150 | + | ||||
| 3151 | + | // HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository | |||
| 3152 | + | func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 3153 | + | ret := _mock.Called(ctx, domain, before) | |||
| 3154 | + | ||||
| 3155 | + | if len(ret) == 0 { | |||
| 3156 | + | panic("no return value specified for HasEntriesByDomainBefore") | |||
| 3157 | + | } | |||
| 3158 | + | ||||
| 3159 | + | var r0 bool | |||
| 3160 | + | var r1 error | |||
| 3161 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok { | |||
| 3162 | + | return returnFunc(ctx, domain, before) | |||
| 3163 | + | } | |||
| 3164 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok { | |||
| 3165 | + | r0 = returnFunc(ctx, domain, before) | |||
| 3166 | + | } else { | |||
| 3167 | + | r0 = ret.Get(0).(bool) | |||
| 3168 | + | } | |||
| 3169 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { | |||
| 3170 | + | r1 = returnFunc(ctx, domain, before) | |||
| 3171 | + | } else { | |||
| 3172 | + | r1 = ret.Error(1) | |||
| 3173 | + | } | |||
| 3174 | + | return r0, r1 | |||
| 3175 | + | } | |||
| 3176 | + | ||||
| 3177 | + | // MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore' | |||
| 3178 | + | type MockHistoryRepository_HasEntriesByDomainBefore_Call struct { | |||
| 3179 | + | *mock.Call | |||
| 3180 | + | } | |||
| 3181 | + | ||||
| 3182 | + | // HasEntriesByDomainBefore is a helper method to define mock.On call | |||
| 3183 | + | // - ctx context.Context | |||
| 3184 | + | // - domain string | |||
| 3185 | + | // - before time.Time | |||
| 3186 | + | func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx interface{}, domain interface{}, before interface{}) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3187 | + | return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)} | |||
| 3188 | + | } | |||
| 3189 | + | ||||
| 3190 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3191 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 3192 | + | var arg0 context.Context | |||
| 3193 | + | if args[0] != nil { | |||
| 3194 | + | arg0 = args[0].(context.Context) | |||
| 3195 | + | } | |||
| 3196 | + | var arg1 string | |||
| 3197 | + | if args[1] != nil { | |||
| 3198 | + | arg1 = args[1].(string) | |||
| 3199 | + | } | |||
| 3200 | + | var arg2 time.Time | |||
| 3201 | + | if args[2] != nil { | |||
| 3202 | + | arg2 = args[2].(time.Time) | |||
| 3203 | + | } | |||
| 3204 | + | run( | |||
| 3205 | + | arg0, | |||
| 3206 | + | arg1, | |||
| 3207 | + | arg2, | |||
| 3208 | + | ) | |||
| 3209 | + | }) | |||
| 3210 | + | return _c | |||
| 3211 | + | } | |||
| 3212 | + | ||||
| 3213 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3214 | + | _c.Call.Return(b, err) | |||
| 3215 | + | return _c | |||
| 3216 | + | } | |||
| 3217 | + | ||||
| 3218 | + | func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call { | |||
| 3219 | + | _c.Call.Return(run) | |||
| 3220 | + | return _c | |||
| 3221 | + | } | |||
| 3222 | + | ||||
| 2794 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | 3223 | // IncrementVisitCount provides a mock function for the type MockHistoryRepository | ||
| 2795 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | 3224 | func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | ||
| 2796 | ret := _mock.Called(ctx, url) | 3225 | ret := _mock.Called(ctx, url) | ||
M internal/domain/url/normalize.go +27 -1
| @@ -178,7 +178,33 @@ func ExtractDomain(rawURL string) string { | |||||
| 178 | if err != nil || parsed.Host == "" { | 178 | if err != nil || parsed.Host == "" { | ||
| 179 | return "" | 179 | return "" | ||
| 180 | } | 180 | } | ||
| 181 | - | return strings.TrimPrefix(parsed.Host, "www.") | 181 | + | return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") |
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // CanonicalDomain extracts a normalized domain from either a URL string or a | |||
| 185 | + | // host-like domain string. It keeps non-default ports so callers that need to | |||
| 186 | + | // round-trip repository domain keys do not lose identity. | |||
| 187 | + | func CanonicalDomain(raw string) string { | |||
| 188 | + | raw = strings.TrimSpace(strings.ToLower(raw)) | |||
| 189 | + | if raw == "" { | |||
| 190 | + | return "" | |||
| 191 | + | } | |||
| 192 | + | if domain := ExtractDomain(raw); domain != "" { | |||
| 193 | + | return domain | |||
| 194 | + | } | |||
| 195 | + | if idx := strings.IndexAny(raw, "/?#"); idx >= 0 { | |||
| 196 | + | raw = raw[:idx] | |||
| 197 | + | } | |||
| 198 | + | return strings.TrimPrefix(raw, "www.") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // DisplayDomain returns a user-facing domain label with any port removed. | |||
| 202 | + | func DisplayDomain(raw string) string { | |||
| 203 | + | domain := CanonicalDomain(raw) | |||
| 204 | + | if host, _, err := net.SplitHostPort(domain); err == nil { | |||
| 205 | + | return host | |||
| 206 | + | } | |||
| 207 | + | return domain | |||
| 182 | } | 208 | } | ||
| 183 | 209 | ||||
| 184 | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | 210 | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | ||
M internal/domain/url/normalize_test.go +57 -4
| @@ -34,8 +34,8 @@ func TestNormalize(t *testing.T) { | |||||
| 34 | }, | 34 | }, | ||
| 35 | { | 35 | { | ||
| 36 | name: "dumb scheme unchanged", | 36 | name: "dumb scheme unchanged", | ||
| 37 | - | input: "dumb://home", | 37 | + | input: "dumb://history", |
| 38 | - | want: "dumb://home", | 38 | + | want: "dumb://history", |
| 39 | }, | 39 | }, | ||
| 40 | { | 40 | { | ||
| 41 | name: "about scheme unchanged", | 41 | name: "about scheme unchanged", | ||
| @@ -347,7 +347,7 @@ func TestLooksLikeURL(t *testing.T) { | |||||
| 347 | }, | 347 | }, | ||
| 348 | { | 348 | { | ||
| 349 | name: "dumb URL", | 349 | name: "dumb URL", | ||
| 350 | - | input: "dumb://home", | 350 | + | input: "dumb://history", |
| 351 | want: true, | 351 | want: true, | ||
| 352 | }, | 352 | }, | ||
| 353 | { | 353 | { | ||
| @@ -506,7 +506,7 @@ func TestIsExternalScheme(t *testing.T) { | |||||
| 506 | }, | 506 | }, | ||
| 507 | { | 507 | { | ||
| 508 | name: "dumb scheme", | 508 | name: "dumb scheme", | ||
| 509 | - | input: "dumb://home", | 509 | + | input: "dumb://history", |
| 510 | want: false, | 510 | want: false, | ||
| 511 | }, | 511 | }, | ||
| 512 | { | 512 | { | ||
| @@ -692,3 +692,56 @@ func TestExtractOrigin_InvalidURI(t *testing.T) { | |||||
| 692 | }) | 692 | }) | ||
| 693 | } | 693 | } | ||
| 694 | } | 694 | } | ||
| 695 | + | ||||
| 696 | + | func TestCanonicalDomain(t *testing.T) { | |||
| 697 | + | tests := []struct { | |||
| 698 | + | name string | |||
| 699 | + | input string | |||
| 700 | + | want string | |||
| 701 | + | }{ | |||
| 702 | + | {name: "empty", input: "", want: ""}, | |||
| 703 | + | {name: "whitespace only", input: " ", want: ""}, | |||
| 704 | + | {name: "url", input: "https://www.example.com/path", want: "example.com"}, | |||
| 705 | + | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"}, | |||
| 706 | + | {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"}, | |||
| 707 | + | {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"}, | |||
| 708 | + | {name: "domain", input: "www.example.com", want: "example.com"}, | |||
| 709 | + | {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"}, | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | for _, tt := range tests { | |||
| 713 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 714 | + | if got := CanonicalDomain(tt.input); got != tt.want { | |||
| 715 | + | t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want) | |||
| 716 | + | } | |||
| 717 | + | }) | |||
| 718 | + | } | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | func TestDisplayDomain(t *testing.T) { | |||
| 722 | + | tests := []struct { | |||
| 723 | + | name string | |||
| 724 | + | input string | |||
| 725 | + | want string | |||
| 726 | + | }{ | |||
| 727 | + | {name: "empty", input: "", want: ""}, | |||
| 728 | + | {name: "whitespace only", input: " ", want: ""}, | |||
| 729 | + | {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"}, | |||
| 730 | + | {name: "url without port", input: "https://www.example.com/path", want: "example.com"}, | |||
| 731 | + | {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"}, | |||
| 732 | + | {name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"}, | |||
| 733 | + | {name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"}, | |||
| 734 | + | {name: "invalid url", input: "%zz", want: "%zz"}, | |||
| 735 | + | {name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"}, | |||
| 736 | + | {name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"}, | |||
| 737 | + | {name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"}, | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | for _, tt := range tests { | |||
| 741 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 742 | + | if got := DisplayDomain(tt.input); got != tt.want { | |||
| 743 | + | t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want) | |||
| 744 | + | } | |||
| 745 | + | }) | |||
| 746 | + | } | |||
| 747 | + | } | |||
A internal/infrastructure/cef/favicon_systemviews.go +15 -0
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 5 | + | var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil) | |||
| 6 | + | ||||
| 7 | + | // SetSystemviewFaviconService exposes the local favicon cache to internal | |||
| 8 | + | // systemview pages. The scheme handler only serves cached PNGs; it does not | |||
| 9 | + | // fetch remote favicons on behalf of History rendering. | |||
| 10 | + | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |||
| 11 | + | if e == nil || e.schemeHandler == nil { | |||
| 12 | + | return | |||
| 13 | + | } | |||
| 14 | + | e.schemeHandler.setFaviconService(service) | |||
| 15 | + | } | |||
M internal/infrastructure/cef/internal_urls.go +4 -4
| @@ -41,7 +41,7 @@ func toActualInternalURL(raw string) string { | |||||
| 41 | if page == "" { | 41 | if page == "" { | ||
| 42 | page = parsed.Opaque | 42 | page = parsed.Opaque | ||
| 43 | } | 43 | } | ||
| 44 | - | if page == "" { | 44 | + | if page == "" || !isInternalPageHost(page) { |
| 45 | return raw | 45 | return raw | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| @@ -49,8 +49,8 @@ func toActualInternalURL(raw string) string { | |||||
| 49 | trimmedPath := strings.Trim(parsed.Path, "/") | 49 | trimmedPath := strings.Trim(parsed.Path, "/") | ||
| 50 | switch { | 50 | switch { | ||
| 51 | case trimmedPath == "": | 51 | case trimmedPath == "": | ||
| 52 | - | // Keep page roots at /home instead of /home/ so relative assets resolve | 52 | + | // Keep page roots at /history instead of /history/ so relative assets |
| 53 | - | // from the origin root. | 53 | + | // resolve from the origin root. |
| 54 | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): | 54 | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): | ||
| 55 | actualPath = "/" + trimmedPath | 55 | actualPath = "/" + trimmedPath | ||
| 56 | case path.Ext(trimmedPath) != "": | 56 | case path.Ext(trimmedPath) != "": | ||
| @@ -134,7 +134,7 @@ func toConceptualInternalURL(raw string) string { | |||||
| 134 | 134 | ||||
| 135 | func isInternalPageHost(host string) bool { | 135 | func isInternalPageHost(host string) bool { | ||
| 136 | switch host { | 136 | switch host { | ||
| 137 | - | case homePath, configPath, webrtcPath, errorPath: | 137 | + | case historyPath, favoritesPath, configPath, errorPath: |
| 138 | return true | 138 | return true | ||
| 139 | default: | 139 | default: | ||
| 140 | return false | 140 | return false | ||
M internal/infrastructure/cef/internal_urls_test.go +53 -12
| @@ -15,23 +15,43 @@ func TestToActualInternalURL(t *testing.T) { | |||||
| 15 | }{ | 15 | }{ | ||
| 16 | { | 16 | { | ||
| 17 | name: "page root", | 17 | name: "page root", | ||
| 18 | - | in: "dumb://home", | 18 | + | in: "dumb://history", |
| 19 | - | want: "https://dumber.invalid/home", | 19 | + | want: "https://dumber.invalid/history", |
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | name: "error page root", | |||
| 23 | + | in: "dumb://error", | |||
| 24 | + | want: "https://dumber.invalid/error", | |||
| 25 | + | }, | |||
| 26 | + | { | |||
| 27 | + | name: "favorites page root", | |||
| 28 | + | in: "dumb://favorites", | |||
| 29 | + | want: "https://dumber.invalid/favorites", | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "config page root", | |||
| 33 | + | in: "dumb://config", | |||
| 34 | + | want: "https://dumber.invalid/config", | |||
| 20 | }, | 35 | }, | ||
| 21 | { | 36 | { | ||
| 22 | name: "api path stays at origin root", | 37 | name: "api path stays at origin root", | ||
| 23 | - | in: "dumb://home/api/message", | 38 | + | in: "dumb://history/api/message", |
| 24 | want: "https://dumber.invalid/api/message", | 39 | want: "https://dumber.invalid/api/message", | ||
| 25 | }, | 40 | }, | ||
| 41 | + | { | |||
| 42 | + | name: "config api path stays at origin root", | |||
| 43 | + | in: "dumb://config/api/config/default", | |||
| 44 | + | want: "https://dumber.invalid/api/config/default", | |||
| 45 | + | }, | |||
| 26 | { | 46 | { | ||
| 27 | name: "root asset stays at origin root", | 47 | name: "root asset stays at origin root", | ||
| 28 | - | in: "dumb://home/favicon.ico", | 48 | + | in: "dumb://history/favicon.ico", |
| 29 | want: "https://dumber.invalid/favicon.ico", | 49 | want: "https://dumber.invalid/favicon.ico", | ||
| 30 | }, | 50 | }, | ||
| 31 | { | 51 | { | ||
| 32 | name: "page subroute stays under page namespace", | 52 | name: "page subroute stays under page namespace", | ||
| 33 | - | in: "dumb://home/crash?url=https%3A%2F%2Fexample.com", | 53 | + | in: "dumb://history/crash?url=https%3A%2F%2Fexample.com", |
| 34 | - | want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com", | 54 | + | want: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com", |
| 35 | }, | 55 | }, | ||
| 36 | { | 56 | { | ||
| 37 | name: "actual internal URL unchanged", | 57 | name: "actual internal URL unchanged", | ||
| @@ -72,11 +92,17 @@ func TestResolveAPIPath(t *testing.T) { | |||||
| 72 | ok: true, | 92 | ok: true, | ||
| 73 | }, | 93 | }, | ||
| 74 | { | 94 | { | ||
| 75 | - | name: "conceptual page api path", | 95 | + | name: "conceptual history api path", |
| 76 | - | in: "dumb://home/api/message", | 96 | + | in: "dumb://history/api/message", |
| 77 | want: "/api/message", | 97 | want: "/api/message", | ||
| 78 | ok: true, | 98 | ok: true, | ||
| 79 | }, | 99 | }, | ||
| 100 | + | { | |||
| 101 | + | name: "conceptual config api path", | |||
| 102 | + | in: "dumb://config/api/config/default", | |||
| 103 | + | want: "/api/config/default", | |||
| 104 | + | ok: true, | |||
| 105 | + | }, | |||
| 80 | { | 106 | { | ||
| 81 | name: "actual internal api path", | 107 | name: "actual internal api path", | ||
| 82 | in: "https://dumber.invalid/api/focus-sync", | 108 | in: "https://dumber.invalid/api/focus-sync", | ||
| @@ -120,13 +146,28 @@ func TestToConceptualInternalURL(t *testing.T) { | |||||
| 120 | }{ | 146 | }{ | ||
| 121 | { | 147 | { | ||
| 122 | name: "page root", | 148 | name: "page root", | ||
| 123 | - | in: "https://dumber.invalid/home", | 149 | + | in: "https://dumber.invalid/history", |
| 124 | - | want: "dumb://home", | 150 | + | want: "dumb://history", |
| 151 | + | }, | |||
| 152 | + | { | |||
| 153 | + | name: "error page root", | |||
| 154 | + | in: "https://dumber.invalid/error", | |||
| 155 | + | want: "dumb://error", | |||
| 156 | + | }, | |||
| 157 | + | { | |||
| 158 | + | name: "favorites page root", | |||
| 159 | + | in: "https://dumber.invalid/favorites", | |||
| 160 | + | want: "dumb://favorites", | |||
| 161 | + | }, | |||
| 162 | + | { | |||
| 163 | + | name: "config page root", | |||
| 164 | + | in: "https://dumber.invalid/config", | |||
| 165 | + | want: "dumb://config", | |||
| 125 | }, | 166 | }, | ||
| 126 | { | 167 | { | ||
| 127 | name: "page subroute", | 168 | name: "page subroute", | ||
| 128 | - | in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com", | 169 | + | in: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com", |
| 129 | - | want: "dumb://home/crash?url=https%3A%2F%2Fexample.com", | 170 | + | want: "dumb://history/crash?url=https%3A%2F%2Fexample.com", |
| 130 | }, | 171 | }, | ||
| 131 | { | 172 | { | ||
| 132 | name: "root asset is left unchanged", | 173 | name: "root asset is left unchanged", | ||
M internal/infrastructure/cef/popup_opener_bridge_test.go +1 -1
| @@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) { | 39 | func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) { | ||
| 40 | - | require.Equal(t, actualInternalOrigin, originFromURL("dumb://home")) | 40 | + | require.Equal(t, actualInternalOrigin, originFromURL("dumb://history")) |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) { | 43 | func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) { | ||
M internal/infrastructure/cef/scheme_handler.go +505 -56
| @@ -1,24 +1,32 @@ | |||||
| 1 | package 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 | + | "os" |
| 17 | + | "path" | |||
| 18 | + | "strconv" | |||
| 14 | "strings" | 19 | "strings" | ||
| 15 | "sync" | 20 | "sync" | ||
| 16 | "time" | 21 | "time" | ||
| 17 | "unsafe" | 22 | "unsafe" | ||
| 18 | 23 | ||||
| 24 | + | "github.com/andybalholm/brotli" | |||
| 19 | purecef "github.com/bnema/purego-cef/cef" | 25 | purecef "github.com/bnema/purego-cef/cef" | ||
| 20 | 26 | ||||
| 21 | "github.com/bnema/dumber/internal/application/port" | 27 | "github.com/bnema/dumber/internal/application/port" | ||
| 28 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 29 | + | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 22 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 30 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 23 | "github.com/bnema/dumber/internal/logging" | 31 | "github.com/bnema/dumber/internal/logging" | ||
| 24 | "github.com/rs/zerolog" | 32 | "github.com/rs/zerolog" | ||
| @@ -26,20 +34,22 @@ import ( | |||||
| 26 | 34 | ||||
| 27 | // Scheme path constants matching WebKit's naming. | 35 | // Scheme path constants matching WebKit's naming. | ||
| 28 | const ( | 36 | const ( | ||
| 29 | - | homePath = "home" | 37 | + | historyPath = "history" |
| 38 | + | favoritesPath = "favorites" | |||
| 30 | configPath = "config" | 39 | configPath = "config" | ||
| 31 | - | webrtcPath = "webrtc" | |||
| 32 | errorPath = "error" | 40 | errorPath = "error" | ||
| 33 | indexHTML = "index.html" | 41 | indexHTML = "index.html" | ||
| 34 | maxSchemeTruncatedURLLength = 240 | 42 | maxSchemeTruncatedURLLength = 240 | ||
| 43 | + | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | |||
| 44 | + | systemviewsAssetDir = "systemviews" | |||
| 35 | ) | 45 | ) | ||
| 36 | 46 | ||||
| 37 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | 47 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | ||
| 38 | var pageRootFiles = map[string]string{ | 48 | var pageRootFiles = map[string]string{ | ||
| 39 | - | homePath: indexHTML, | 49 | + | historyPath: indexHTML, |
| 40 | - | configPath: "config.html", | 50 | + | favoritesPath: indexHTML, |
| 41 | - | webrtcPath: "webrtc.html", | 51 | + | configPath: indexHTML, |
| 42 | - | errorPath: "error.html", | 52 | + | errorPath: indexHTML, |
| 43 | } | 53 | } | ||
| 44 | 54 | ||||
| 45 | var cefNewResourceHandler = purecef.NewResourceHandler | 55 | var cefNewResourceHandler = purecef.NewResourceHandler | ||
| @@ -50,6 +60,7 @@ type dumbSchemeHandler struct { | |||||
| 50 | ctx context.Context | 60 | ctx context.Context | ||
| 51 | messageRouter *MessageRouter | 61 | messageRouter *MessageRouter | ||
| 52 | transcoder port.MediaTranscoder | 62 | transcoder port.MediaTranscoder | ||
| 63 | + | faviconService port.FaviconService | |||
| 53 | assets embed.FS | 64 | assets embed.FS | ||
| 54 | assetsSet bool | 65 | assetsSet bool | ||
| 55 | assetDir string | 66 | assetDir string | ||
| @@ -100,14 +111,14 @@ func newDumbSchemeHandler( | |||||
| 100 | ctx: ctx, | 111 | ctx: ctx, | ||
| 101 | messageRouter: router, | 112 | messageRouter: router, | ||
| 102 | transcoder: transcoder, | 113 | transcoder: transcoder, | ||
| 103 | - | assetDir: "webui", | 114 | + | assetDir: systemviewsAssetDir, |
| 104 | logger: log.With().Str("component", "scheme-handler").Logger(), | 115 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 105 | currentConfigPayload: currentConfigPayload, | 116 | currentConfigPayload: currentConfigPayload, | ||
| 106 | defaultConfigPayload: defaultConfigPayload, | 117 | defaultConfigPayload: defaultConfigPayload, | ||
| 107 | }, nil | 118 | }, nil | ||
| 108 | } | 119 | } | ||
| 109 | 120 | ||||
| 110 | - | // setAssets sets the embedded filesystem containing webui assets. | 121 | + | // setAssets sets the embedded filesystem containing systemviews assets. |
| 111 | func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | 122 | func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | ||
| 112 | h.mu.Lock() | 123 | h.mu.Lock() | ||
| 113 | defer h.mu.Unlock() | 124 | defer h.mu.Unlock() | ||
| @@ -116,6 +127,12 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||||
| 116 | h.logger.Debug().Msg("assets filesystem configured") | 127 | h.logger.Debug().Msg("assets filesystem configured") | ||
| 117 | } | 128 | } | ||
| 118 | 129 | ||||
| 130 | + | func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) { | |||
| 131 | + | h.mu.Lock() | |||
| 132 | + | defer h.mu.Unlock() | |||
| 133 | + | h.faviconService = service | |||
| 134 | + | } | |||
| 135 | + | ||||
| 119 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | 136 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | ||
| 120 | // for requests to either dumb:// or the internal https origin. | 137 | // for requests to either dumb:// or the internal https origin. | ||
| 121 | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | 138 | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | ||
| @@ -162,17 +179,28 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s | |||||
| 162 | } | 179 | } | ||
| 163 | 180 | ||||
| 164 | // handleAPI routes API requests to the message router or built-in handlers. | 181 | // handleAPI routes API requests to the message router or built-in handlers. | ||
| 165 | - | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | 182 | + | func (h *dumbSchemeHandler) handleAPI( |
| 183 | + | browser purecef.Browser, | |||
| 184 | + | method string, | |||
| 185 | + | requestPath string, | |||
| 186 | + | request purecef.Request, | |||
| 187 | + | ) purecef.ResourceHandler { | |||
| 166 | if strings.EqualFold(method, http.MethodOptions) { | 188 | if strings.EqualFold(method, http.MethodOptions) { | ||
| 189 | + | if requestPath == "/api/config" || requestPath == "/api/config/default" { | |||
| 190 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |||
| 191 | + | return denied | |||
| 192 | + | } | |||
| 193 | + | return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |||
| 194 | + | } | |||
| 167 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | 195 | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | ||
| 168 | } | 196 | } | ||
| 169 | if isAPIGetMethod(method) { | 197 | if isAPIGetMethod(method) { | ||
| 170 | - | if handler, ok := h.handleAPIGet(path, request); ok { | 198 | + | if handler, ok := h.handleAPIGet(requestPath, request); ok { |
| 171 | return handler | 199 | return handler | ||
| 172 | } | 200 | } | ||
| 173 | } | 201 | } | ||
| 174 | if isAPIPostMethod(method) { | 202 | if isAPIPostMethod(method) { | ||
| 175 | - | if handler, ok := h.handleAPIPost(browser, path, request); ok { | 203 | + | if handler, ok := h.handleAPIPost(browser, requestPath, request); ok { |
| 176 | return handler | 204 | return handler | ||
| 177 | } | 205 | } | ||
| 178 | } | 206 | } | ||
| @@ -187,21 +215,33 @@ func isAPIPostMethod(method string) bool { | |||||
| 187 | return strings.EqualFold(method, http.MethodPost) | 215 | return strings.EqualFold(method, http.MethodPost) | ||
| 188 | } | 216 | } | ||
| 189 | 217 | ||||
| 190 | - | func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) { | 218 | + | func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) { |
| 191 | - | switch path { | 219 | + | switch requestPath { |
| 192 | case "/api/config": | 220 | case "/api/config": | ||
| 221 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |||
| 222 | + | return denied, true | |||
| 223 | + | } | |||
| 193 | return h.handleConfigAPI(h.currentConfigPayload), true | 224 | return h.handleConfigAPI(h.currentConfigPayload), true | ||
| 194 | case "/api/config/default": | 225 | case "/api/config/default": | ||
| 226 | + | if denied := h.rejectUntrustedConfigRequester(request); denied != nil { | |||
| 227 | + | return denied, true | |||
| 228 | + | } | |||
| 195 | return h.handleConfigAPI(h.defaultConfigPayload), true | 229 | return h.handleConfigAPI(h.defaultConfigPayload), true | ||
| 196 | case "/api/transcode": | 230 | case "/api/transcode": | ||
| 197 | return h.handleTranscodeAPI(request), true | 231 | return h.handleTranscodeAPI(request), true | ||
| 232 | + | case "/api/favicon": | |||
| 233 | + | return h.handleFaviconAPI(request), true | |||
| 198 | default: | 234 | default: | ||
| 199 | return nil, false | 235 | return nil, false | ||
| 200 | } | 236 | } | ||
| 201 | } | 237 | } | ||
| 202 | 238 | ||||
| 203 | - | func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) { | 239 | + | func (h *dumbSchemeHandler) handleAPIPost( |
| 204 | - | switch path { | 240 | + | browser purecef.Browser, |
| 241 | + | requestPath string, | |||
| 242 | + | request purecef.Request, | |||
| 243 | + | ) (purecef.ResourceHandler, bool) { | |||
| 244 | + | switch requestPath { | |||
| 205 | case "/api/message": | 245 | case "/api/message": | ||
| 206 | return h.handleMessageAPI(request), true | 246 | return h.handleMessageAPI(request), true | ||
| 207 | case "/api/clipboard-set": | 247 | case "/api/clipboard-set": | ||
| @@ -224,6 +264,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, | |||||
| 224 | } | 264 | } | ||
| 225 | 265 | ||||
| 226 | func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler { | 266 | func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 267 | + | if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil { | |||
| 268 | + | return denied | |||
| 269 | + | } | |||
| 227 | body := readBodyFromHeader(request) | 270 | body := readBodyFromHeader(request) | ||
| 228 | if body == nil { | 271 | if body == nil { | ||
| 229 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | 272 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | ||
| @@ -235,6 +278,98 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re | |||||
| 235 | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | 278 | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) | ||
| 236 | } | 279 | } | ||
| 237 | 280 | ||||
| 281 | + | func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler { | |||
| 282 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |||
| 283 | + | if origin == "" || strings.EqualFold(origin, actualInternalOrigin) { | |||
| 284 | + | return nil | |||
| 285 | + | } | |||
| 286 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"}) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler { | |||
| 290 | + | return h.rejectUntrustedSystemviewRequester(request) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler { | |||
| 294 | + | if request == nil { | |||
| 295 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 296 | + | } | |||
| 297 | + | origin := strings.TrimSpace(request.GetHeaderByName("Origin")) | |||
| 298 | + | if origin != "" { | |||
| 299 | + | if isTrustedSystemviewURL(origin) { | |||
| 300 | + | return nil | |||
| 301 | + | } | |||
| 302 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 303 | + | } | |||
| 304 | + | referrer := strings.TrimSpace(request.GetReferrerURL()) | |||
| 305 | + | if referrer == "" { | |||
| 306 | + | referrer = strings.TrimSpace(request.GetHeaderByName("Referer")) | |||
| 307 | + | } | |||
| 308 | + | if !isTrustedSystemviewURL(referrer) { | |||
| 309 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 310 | + | } | |||
| 311 | + | return nil | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | const systemviewFaviconSize = 32 | |||
| 315 | + | ||||
| 316 | + | func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler { | |||
| 317 | + | if denied := h.rejectUntrustedFaviconRequester(request); denied != nil { | |||
| 318 | + | return denied | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | h.mu.RLock() | |||
| 322 | + | service := h.faviconService | |||
| 323 | + | h.mu.RUnlock() | |||
| 324 | + | if service == nil { | |||
| 325 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"}) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | parsed, err := url.Parse(request.GetURL()) | |||
| 329 | + | if err != nil { | |||
| 330 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |||
| 331 | + | } | |||
| 332 | + | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) | |||
| 333 | + | if domain == "" { | |||
| 334 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"}) | |||
| 335 | + | } | |||
| 336 | + | size := systemviewFaviconSize | |||
| 337 | + | if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" { | |||
| 338 | + | parsedSize, parseErr := strconv.Atoi(rawSize) | |||
| 339 | + | if parseErr != nil || parsedSize != systemviewFaviconSize { | |||
| 340 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"}) | |||
| 341 | + | } | |||
| 342 | + | size = parsedSize | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | return newFaviconResourceHandler(service, domain, size) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler { | |||
| 349 | + | return h.rejectUntrustedSystemviewRequester(request) | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func isTrustedSystemviewURL(raw string) bool { | |||
| 353 | + | parsed, err := url.Parse(strings.TrimSpace(raw)) | |||
| 354 | + | if err != nil || parsed.Scheme == "" { | |||
| 355 | + | return false | |||
| 356 | + | } | |||
| 357 | + | if strings.EqualFold(parsed.Scheme, actualInternalScheme) { | |||
| 358 | + | return strings.EqualFold(parsed.Host, actualInternalHost) | |||
| 359 | + | } | |||
| 360 | + | if !strings.EqualFold(parsed.Scheme, "dumb") { | |||
| 361 | + | return false | |||
| 362 | + | } | |||
| 363 | + | host := parsed.Host | |||
| 364 | + | if host == "" { | |||
| 365 | + | host = parsed.Opaque | |||
| 366 | + | } | |||
| 367 | + | if idx := strings.IndexAny(host, "/?#"); idx >= 0 { | |||
| 368 | + | host = host[:idx] | |||
| 369 | + | } | |||
| 370 | + | return isInternalPageHost(host) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 238 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | 373 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 239 | if h.transcoder == nil { | 374 | if h.transcoder == nil { | ||
| 240 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | 375 | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | ||
| @@ -255,7 +390,6 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 255 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | 390 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | ||
| 256 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | 391 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | ||
| 257 | } | 392 | } | ||
| 258 | - | ||||
| 259 | headers := make(map[string]string) | 393 | headers := make(map[string]string) | ||
| 260 | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | 394 | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | ||
| 261 | headers["User-Agent"] = userAgent | 395 | headers["User-Agent"] = userAgent | ||
| @@ -281,12 +415,48 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 281 | headers: headers, | 415 | headers: headers, | ||
| 282 | ctx: ctx, | 416 | ctx: ctx, | ||
| 283 | cancel: cancel, | 417 | cancel: cancel, | ||
| 418 | + | validateSource: func(ctx context.Context) error { | |||
| 419 | + | return validateTranscodeSourceURL(ctx, sourceParsed) | |||
| 420 | + | }, | |||
| 284 | logf: func() zerolog.Logger { | 421 | logf: func() zerolog.Logger { | ||
| 285 | return h.logger.With().Str("component", "scheme-transcoding").Logger() | 422 | return h.logger.With().Str("component", "scheme-transcoding").Logger() | ||
| 286 | }, | 423 | }, | ||
| 287 | }) | 424 | }) | ||
| 288 | } | 425 | } | ||
| 289 | 426 | ||||
| 427 | + | func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error { | |||
| 428 | + | if source == nil || source.Host == "" { | |||
| 429 | + | return fmt.Errorf("invalid src") | |||
| 430 | + | } | |||
| 431 | + | host := source.Hostname() | |||
| 432 | + | if host == "" { | |||
| 433 | + | return fmt.Errorf("invalid src host") | |||
| 434 | + | } | |||
| 435 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 436 | + | if isBlockedTranscodeIP(ip) { | |||
| 437 | + | return fmt.Errorf("private src host not allowed") | |||
| 438 | + | } | |||
| 439 | + | return nil | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 443 | + | defer cancel() | |||
| 444 | + | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 445 | + | if err != nil || len(addrs) == 0 { | |||
| 446 | + | return fmt.Errorf("src host could not be resolved") | |||
| 447 | + | } | |||
| 448 | + | for _, addr := range addrs { | |||
| 449 | + | if isBlockedTranscodeIP(addr.IP) { | |||
| 450 | + | return fmt.Errorf("private src host not allowed") | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | return nil | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 457 | + | return netguard.IsBlockedTranscodeIP(ip) | |||
| 458 | + | } | |||
| 459 | + | ||||
| 290 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | 460 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | ||
| 291 | if build == nil { | 461 | if build == nil { | ||
| 292 | return nil, fmt.Errorf("config payload builder not configured") | 462 | return nil, fmt.Errorf("config payload builder not configured") | ||
| @@ -298,9 +468,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |||||
| 298 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | 468 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | ||
| 299 | data, err := resolveConfigPayload(build) | 469 | data, err := resolveConfigPayload(build) | ||
| 300 | if err != nil { | 470 | if err != nil { | ||
| 301 | - | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | 471 | + | return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) |
| 302 | } | 472 | } | ||
| 303 | - | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data) | 473 | + | return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data) |
| 304 | } | 474 | } | ||
| 305 | 475 | ||||
| 306 | // handleClipboardSet receives copied text from JS copy/cut events and writes | 476 | // handleClipboardSet receives copied text from JS copy/cut events and writes | ||
| @@ -485,20 +655,26 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri | |||||
| 485 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | 655 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | ||
| 486 | h.mu.RLock() | 656 | h.mu.RLock() | ||
| 487 | hasAssets := h.assetsSet | 657 | hasAssets := h.assetsSet | ||
| 488 | - | assetDir := h.assetDir | |||
| 489 | h.mu.RUnlock() | 658 | h.mu.RUnlock() | ||
| 490 | 659 | ||||
| 491 | if !hasAssets { | 660 | if !hasAssets { | ||
| 492 | return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured") | 661 | return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured") | ||
| 493 | } | 662 | } | ||
| 494 | 663 | ||||
| 495 | - | relPath, ok := resolveAssetPath(u) | 664 | + | assetDir, relPath, ok := resolveAssetPath(u) |
| 496 | if !ok { | 665 | if !ok { | ||
| 497 | return h.newErrorResourceHandler(http.StatusNotFound, "Page not found") | 666 | return h.newErrorResourceHandler(http.StatusNotFound, "Page not found") | ||
| 498 | } | 667 | } | ||
| 668 | + | if assetDir == "" { | |||
| 669 | + | assetDir = h.assetDir | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath) | |||
| 673 | + | if !ok { | |||
| 674 | + | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | |||
| 675 | + | } | |||
| 499 | 676 | ||||
| 500 | - | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | 677 | + | data, err := readAssetWithEncoding(h.assets, fullPath, relPath) |
| 501 | - | data, err := fs.ReadFile(h.assets, fullPath) | |||
| 502 | if err != nil { | 678 | if err != nil { | ||
| 503 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | 679 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | ||
| 504 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | 680 | return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found") | ||
| @@ -511,14 +687,54 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||||
| 511 | Int("size", len(data)). | 687 | Int("size", len(data)). | ||
| 512 | Msg("serving asset") | 688 | Msg("serving asset") | ||
| 513 | 689 | ||||
| 514 | - | return h.newRawResourceHandler(http.StatusOK, contentType, data) | 690 | + | return newStaticResourceHandler(http.StatusOK, contentType, data, nil) |
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |||
| 694 | + | assetDir = strings.Trim(assetDir, "/") | |||
| 695 | + | if assetDir != systemviewsAssetDir { | |||
| 696 | + | return "", "", false | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | relPath = strings.TrimLeft(relPath, "/") | |||
| 700 | + | if relPath == "" || strings.ContainsRune(relPath, '\x00') { | |||
| 701 | + | return "", "", false | |||
| 702 | + | } | |||
| 703 | + | ||||
| 704 | + | cleanRelPath = path.Clean(relPath) | |||
| 705 | + | if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) { | |||
| 706 | + | return "", "", false | |||
| 707 | + | } | |||
| 708 | + | ||||
| 709 | + | fullPath = path.Join(assetDir, cleanRelPath) | |||
| 710 | + | if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") { | |||
| 711 | + | return "", "", false | |||
| 712 | + | } | |||
| 713 | + | return fullPath, cleanRelPath, true | |||
| 714 | + | } | |||
| 715 | + | ||||
| 716 | + | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | |||
| 717 | + | if strings.HasSuffix(relPath, ".wasm") { | |||
| 718 | + | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |||
| 719 | + | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |||
| 720 | + | if err != nil { | |||
| 721 | + | return nil, err | |||
| 722 | + | } | |||
| 723 | + | if len(data) > maxSystemviewsWASMBytes { | |||
| 724 | + | return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |||
| 725 | + | } | |||
| 726 | + | return data, nil | |||
| 727 | + | } | |||
| 728 | + | } | |||
| 729 | + | data, err := fs.ReadFile(assets, fullPath) | |||
| 730 | + | return data, err | |||
| 515 | } | 731 | } | ||
| 516 | 732 | ||||
| 517 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | 733 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | ||
| 518 | - | // to a relative asset path inside assets/webui. | 734 | + | // to a relative asset path inside assets/systemviews. |
| 519 | - | func resolveAssetPath(u *url.URL) (string, bool) { | 735 | + | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 520 | if u == nil { | 736 | if u == nil { | ||
| 521 | - | return "", false | 737 | + | return "", "", false |
| 522 | } | 738 | } | ||
| 523 | 739 | ||||
| 524 | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | 740 | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | ||
| @@ -528,57 +744,70 @@ func resolveAssetPath(u *url.URL) (string, bool) { | |||||
| 528 | return resolveConceptualAssetPath(u) | 744 | return resolveConceptualAssetPath(u) | ||
| 529 | } | 745 | } | ||
| 530 | 746 | ||||
| 531 | - | func resolveConceptualAssetPath(u *url.URL) (string, bool) { | 747 | + | func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 532 | if u == nil { | 748 | if u == nil { | ||
| 533 | - | return "", false | 749 | + | return "", "", false |
| 534 | } | 750 | } | ||
| 535 | 751 | ||||
| 536 | if root, ok := pageRootFiles[u.Host]; ok { | 752 | if root, ok := pageRootFiles[u.Host]; ok { | ||
| 537 | - | path := strings.TrimPrefix(u.Path, "/") | 753 | + | assetPath := strings.TrimPrefix(u.Path, "/") |
| 538 | - | if path == "" { | 754 | + | if assetPath == "" { |
| 539 | - | return root, true | 755 | + | return assetDirForPageHost(u.Host), root, true |
| 540 | } | 756 | } | ||
| 541 | // Don't serve API paths as assets. | 757 | // Don't serve API paths as assets. | ||
| 542 | - | if strings.HasPrefix(path, "api/") { | 758 | + | if strings.HasPrefix(assetPath, "api/") { |
| 543 | - | return "", false | 759 | + | return "", "", false |
| 544 | } | 760 | } | ||
| 545 | - | return path, true | 761 | + | return assetDirForPageHost(u.Host), assetPath, true |
| 546 | } | 762 | } | ||
| 547 | 763 | ||||
| 548 | - | // Handle opaque URLs like dumb:home. | 764 | + | // Handle opaque URLs like dumb:history. |
| 549 | if root, ok := pageRootFiles[u.Opaque]; ok { | 765 | if root, ok := pageRootFiles[u.Opaque]; ok { | ||
| 550 | - | return root, true | 766 | + | return assetDirForPageHost(u.Opaque), root, true |
| 551 | } | 767 | } | ||
| 552 | - | return "", false | 768 | + | return "", "", false |
| 553 | } | 769 | } | ||
| 554 | 770 | ||||
| 555 | - | func resolveActualAssetPath(u *url.URL) (string, bool) { | 771 | + | func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 556 | if u == nil { | 772 | if u == nil { | ||
| 557 | - | return "", false | 773 | + | return "", "", false |
| 558 | } | 774 | } | ||
| 559 | 775 | ||||
| 560 | - | path := strings.Trim(u.Path, "/") | 776 | + | assetPath := strings.Trim(u.Path, "/") |
| 561 | - | if path == "" { | 777 | + | if assetPath == "" { |
| 562 | - | return "", false | 778 | + | return "", "", false |
| 563 | } | 779 | } | ||
| 564 | 780 | ||||
| 565 | - | if root, ok := pageRootFiles[path]; ok { | 781 | + | parts := strings.SplitN(assetPath, "/", 2) |
| 566 | - | return root, true | 782 | + | page := parts[0] |
| 783 | + | if root, ok := pageRootFiles[page]; ok { | |||
| 784 | + | if len(parts) == 1 { | |||
| 785 | + | return assetDirForPageHost(page), root, true | |||
| 786 | + | } | |||
| 787 | + | if page == historyPath && parts[1] == "crash" { | |||
| 788 | + | return "", "", false | |||
| 789 | + | } | |||
| 790 | + | return assetDirForPageHost(page), parts[1], true | |||
| 567 | } | 791 | } | ||
| 568 | 792 | ||||
| 569 | - | if strings.HasPrefix(path, "api/") { | 793 | + | if strings.HasPrefix(assetPath, "api/") { |
| 570 | - | return "", false | 794 | + | return "", "", false |
| 571 | } | 795 | } | ||
| 572 | 796 | ||||
| 573 | - | parts := strings.SplitN(path, "/", 2) | 797 | + | if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") { |
| 574 | - | if len(parts) == 2 && isInternalPageHost(parts[0]) { | 798 | + | return systemviewsAssetDir, assetPath, true |
| 575 | - | if parts[1] == "crash" && parts[0] == homePath { | |||
| 576 | - | return "", false | |||
| 577 | - | } | |||
| 578 | } | 799 | } | ||
| 579 | 800 | ||||
| 580 | - | // Serve root assets like homepage.min.js, style.css and favicon.png. | 801 | + | return "", "", false |
| 581 | - | return path, true | 802 | + | } |
| 803 | + | ||||
| 804 | + | func assetDirForPageHost(host string) string { | |||
| 805 | + | switch host { | |||
| 806 | + | case historyPath, favoritesPath, configPath, errorPath: | |||
| 807 | + | return systemviewsAssetDir | |||
| 808 | + | default: | |||
| 809 | + | return "" | |||
| 810 | + | } | |||
| 582 | } | 811 | } | ||
| 583 | 812 | ||||
| 584 | func isCEFCrashPageURL(u *url.URL) bool { | 813 | func isCEFCrashPageURL(u *url.URL) bool { | ||
| @@ -588,8 +817,8 @@ func isCEFCrashPageURL(u *url.URL) bool { | |||||
| 588 | 817 | ||||
| 589 | switch { | 818 | switch { | ||
| 590 | case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost): | 819 | case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost): | ||
| 591 | - | return strings.Trim(u.Path, "/") == homePath+"/crash" | 820 | + | return strings.Trim(u.Path, "/") == historyPath+"/crash" |
| 592 | - | case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath: | 821 | + | case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath: |
| 593 | return strings.Trim(u.Path, "/") == "crash" | 822 | return strings.Trim(u.Path, "/") == "crash" | ||
| 594 | default: | 823 | default: | ||
| 595 | return false | 824 | return false | ||
| @@ -671,6 +900,10 @@ func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType str | |||||
| 671 | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | 900 | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | ||
| 672 | } | 901 | } | ||
| 673 | 902 | ||||
| 903 | + | func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |||
| 904 | + | return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"}) | |||
| 905 | + | } | |||
| 906 | + | ||||
| 674 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | 907 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | ||
| 675 | escaped := html.EscapeString(msg) | 908 | escaped := html.EscapeString(msg) | ||
| 676 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | 909 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | ||
| @@ -686,6 +919,222 @@ func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef | |||||
| 686 | return h.newAPIRawResourceHandler(status, "application/json", data) | 919 | return h.newAPIRawResourceHandler(status, "application/json", data) | ||
| 687 | } | 920 | } | ||
| 688 | 921 | ||||
| 922 | + | func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler { | |||
| 923 | + | data, err := json.Marshal(v) | |||
| 924 | + | if err != nil { | |||
| 925 | + | fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"}) | |||
| 926 | + | return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback) | |||
| 927 | + | } | |||
| 928 | + | return h.newPrivateAPIRawResourceHandler(status, "application/json", data) | |||
| 929 | + | } | |||
| 930 | + | ||||
| 931 | + | func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler { | |||
| 932 | + | return cefNewResourceHandler(&faviconResourceHandler{ | |||
| 933 | + | service: service, | |||
| 934 | + | domain: domain, | |||
| 935 | + | size: size, | |||
| 936 | + | done: make(chan struct{}), | |||
| 937 | + | headers: map[string]string{"Cache-Control": "no-store"}, | |||
| 938 | + | }) | |||
| 939 | + | } | |||
| 940 | + | ||||
| 941 | + | const maxFaviconMemoryCacheEntries = 256 | |||
| 942 | + | ||||
| 943 | + | var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries) | |||
| 944 | + | ||||
| 945 | + | type faviconCacheEntry struct { | |||
| 946 | + | statusCode int | |||
| 947 | + | contentType string | |||
| 948 | + | data []byte | |||
| 949 | + | } | |||
| 950 | + | ||||
| 951 | + | type faviconMemoryCache struct { | |||
| 952 | + | mu sync.Mutex | |||
| 953 | + | max int | |||
| 954 | + | entries map[string]faviconCacheEntry | |||
| 955 | + | order []string | |||
| 956 | + | } | |||
| 957 | + | ||||
| 958 | + | func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache { | |||
| 959 | + | return &faviconMemoryCache{ | |||
| 960 | + | max: maxEntries, | |||
| 961 | + | entries: make(map[string]faviconCacheEntry), | |||
| 962 | + | order: make([]string, 0, maxEntries), | |||
| 963 | + | } | |||
| 964 | + | } | |||
| 965 | + | ||||
| 966 | + | func faviconCacheKey(domain string, size int) string { | |||
| 967 | + | return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size) | |||
| 968 | + | } | |||
| 969 | + | ||||
| 970 | + | func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) { | |||
| 971 | + | if c == nil || key == "" { | |||
| 972 | + | return faviconCacheEntry{}, false | |||
| 973 | + | } | |||
| 974 | + | c.mu.Lock() | |||
| 975 | + | defer c.mu.Unlock() | |||
| 976 | + | entry, ok := c.entries[key] | |||
| 977 | + | if !ok { | |||
| 978 | + | return faviconCacheEntry{}, false | |||
| 979 | + | } | |||
| 980 | + | c.moveToBackLocked(key) | |||
| 981 | + | return entry, true | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) { | |||
| 985 | + | if c == nil || key == "" || c.max <= 0 { | |||
| 986 | + | return | |||
| 987 | + | } | |||
| 988 | + | c.mu.Lock() | |||
| 989 | + | defer c.mu.Unlock() | |||
| 990 | + | if _, ok := c.entries[key]; !ok { | |||
| 991 | + | c.order = append(c.order, key) | |||
| 992 | + | } else { | |||
| 993 | + | c.moveToBackLocked(key) | |||
| 994 | + | } | |||
| 995 | + | c.entries[key] = entry | |||
| 996 | + | for len(c.order) > c.max { | |||
| 997 | + | oldest := c.order[0] | |||
| 998 | + | c.order = c.order[1:] | |||
| 999 | + | delete(c.entries, oldest) | |||
| 1000 | + | } | |||
| 1001 | + | } | |||
| 1002 | + | ||||
| 1003 | + | func (c *faviconMemoryCache) moveToBackLocked(key string) { | |||
| 1004 | + | for i, candidate := range c.order { | |||
| 1005 | + | if candidate != key { | |||
| 1006 | + | continue | |||
| 1007 | + | } | |||
| 1008 | + | copy(c.order[i:], c.order[i+1:]) | |||
| 1009 | + | c.order[len(c.order)-1] = key | |||
| 1010 | + | return | |||
| 1011 | + | } | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 1014 | + | type faviconResourceHandler struct { | |||
| 1015 | + | service port.FaviconService | |||
| 1016 | + | domain string | |||
| 1017 | + | size int | |||
| 1018 | + | headers map[string]string | |||
| 1019 | + | once sync.Once | |||
| 1020 | + | done chan struct{} | |||
| 1021 | + | data []byte | |||
| 1022 | + | contentType string | |||
| 1023 | + | statusCode int | |||
| 1024 | + | offset int | |||
| 1025 | + | } | |||
| 1026 | + | ||||
| 1027 | + | func (rh *faviconResourceHandler) load() { | |||
| 1028 | + | defer close(rh.done) | |||
| 1029 | + | rh.statusCode = http.StatusNotFound | |||
| 1030 | + | rh.contentType = "application/json" | |||
| 1031 | + | rh.data = []byte(`{"error":"favicon not cached"}`) | |||
| 1032 | + | if rh.service == nil { | |||
| 1033 | + | return | |||
| 1034 | + | } | |||
| 1035 | + | cacheKey := faviconCacheKey(rh.domain, rh.size) | |||
| 1036 | + | if entry, ok := systemviewFaviconCache.get(cacheKey); ok { | |||
| 1037 | + | rh.statusCode = entry.statusCode | |||
| 1038 | + | rh.contentType = entry.contentType | |||
| 1039 | + | rh.data = entry.data | |||
| 1040 | + | return | |||
| 1041 | + | } | |||
| 1042 | + | if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |||
| 1043 | + | return | |||
| 1044 | + | } | |||
| 1045 | + | diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size) | |||
| 1046 | + | if diskPath == "" { | |||
| 1047 | + | return | |||
| 1048 | + | } | |||
| 1049 | + | data, err := os.ReadFile(diskPath) | |||
| 1050 | + | if err != nil || len(data) == 0 { | |||
| 1051 | + | return | |||
| 1052 | + | } | |||
| 1053 | + | rh.statusCode = http.StatusOK | |||
| 1054 | + | rh.contentType = "image/png" | |||
| 1055 | + | rh.data = data | |||
| 1056 | + | systemviewFaviconCache.put(cacheKey, faviconCacheEntry{ | |||
| 1057 | + | statusCode: rh.statusCode, | |||
| 1058 | + | contentType: rh.contentType, | |||
| 1059 | + | data: data, | |||
| 1060 | + | }) | |||
| 1061 | + | } | |||
| 1062 | + | ||||
| 1063 | + | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | |||
| 1064 | + | if handleRequest != nil { | |||
| 1065 | + | *handleRequest = 0 | |||
| 1066 | + | } | |||
| 1067 | + | rh.once.Do(func() { | |||
| 1068 | + | go func() { | |||
| 1069 | + | rh.load() | |||
| 1070 | + | if callback != nil { | |||
| 1071 | + | callback.Cont() | |||
| 1072 | + | } | |||
| 1073 | + | }() | |||
| 1074 | + | }) | |||
| 1075 | + | return 1 | |||
| 1076 | + | } | |||
| 1077 | + | ||||
| 1078 | + | func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 { | |||
| 1079 | + | rh.once.Do(func() { | |||
| 1080 | + | go func() { | |||
| 1081 | + | rh.load() | |||
| 1082 | + | if callback != nil { | |||
| 1083 | + | callback.Cont() | |||
| 1084 | + | } | |||
| 1085 | + | }() | |||
| 1086 | + | }) | |||
| 1087 | + | return 1 | |||
| 1088 | + | } | |||
| 1089 | + | ||||
| 1090 | + | func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) { | |||
| 1091 | + | <-rh.done | |||
| 1092 | + | response.SetStatus(int32(rh.statusCode)) | |||
| 1093 | + | if text := http.StatusText(rh.statusCode); text != "" { | |||
| 1094 | + | response.SetStatusText(text) | |||
| 1095 | + | } | |||
| 1096 | + | mimeType, charset := splitMimeCharset(rh.contentType) | |||
| 1097 | + | response.SetMimeType(mimeType) | |||
| 1098 | + | if charset != "" { | |||
| 1099 | + | response.SetCharset(charset) | |||
| 1100 | + | } | |||
| 1101 | + | for name, value := range rh.headers { | |||
| 1102 | + | response.SetHeaderByName(name, value, 1) | |||
| 1103 | + | } | |||
| 1104 | + | if responseLength != nil { | |||
| 1105 | + | *responseLength = int64(len(rh.data)) | |||
| 1106 | + | } | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |||
| 1110 | + | return 0 | |||
| 1111 | + | } | |||
| 1112 | + | ||||
| 1113 | + | func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 { | |||
| 1114 | + | <-rh.done | |||
| 1115 | + | if rh.offset >= len(rh.data) { | |||
| 1116 | + | return 0 | |||
| 1117 | + | } | |||
| 1118 | + | remaining := len(rh.data) - rh.offset | |||
| 1119 | + | toRead := int(bytesToRead) | |||
| 1120 | + | if toRead > remaining { | |||
| 1121 | + | toRead = remaining | |||
| 1122 | + | } | |||
| 1123 | + | dst := unsafe.Slice((*byte)(dataOut), toRead) | |||
| 1124 | + | copy(dst, rh.data[rh.offset:rh.offset+toRead]) | |||
| 1125 | + | rh.offset += toRead | |||
| 1126 | + | if bytesRead != nil { | |||
| 1127 | + | *bytesRead = int32(toRead) | |||
| 1128 | + | } | |||
| 1129 | + | return 1 | |||
| 1130 | + | } | |||
| 1131 | + | ||||
| 1132 | + | func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |||
| 1133 | + | return 0 | |||
| 1134 | + | } | |||
| 1135 | + | ||||
| 1136 | + | func (rh *faviconResourceHandler) Cancel() {} | |||
| 1137 | + | ||||
| 689 | // Open handles the request immediately (synchronous). | 1138 | // Open handles the request immediately (synchronous). | ||
| 690 | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 { | 1139 | func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 { | ||
| 691 | // Set handleRequest = true (1) to indicate we handle it immediately. | 1140 | // Set handleRequest = true (1) to indicate we handle it immediately. | ||
A internal/infrastructure/cef/scheme_handler_systemviews_test.go +99 -0
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/assets" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | raw string | |||
| 16 | + | wantDir string | |||
| 17 | + | wantRel string | |||
| 18 | + | }{ | |||
| 19 | + | {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML}, | |||
| 20 | + | {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML}, | |||
| 21 | + | {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML}, | |||
| 22 | + | {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | |||
| 23 | + | {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"}, | |||
| 24 | + | {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"}, | |||
| 25 | + | {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML}, | |||
| 26 | + | {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | |||
| 27 | + | {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"}, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | for _, tt := range tests { | |||
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 32 | + | t.Parallel() | |||
| 33 | + | u := mustParseURL(t, tt.raw) | |||
| 34 | + | gotDir, gotRel, ok := resolveAssetPath(u) | |||
| 35 | + | if !ok { | |||
| 36 | + | t.Fatalf("resolveAssetPath(%q) = not ok", tt.raw) | |||
| 37 | + | } | |||
| 38 | + | if gotDir != tt.wantDir || gotRel != tt.wantRel { | |||
| 39 | + | t.Fatalf("resolveAssetPath(%q) = (%q, %q), want (%q, %q)", tt.raw, gotDir, gotRel, tt.wantDir, tt.wantRel) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | |||
| 46 | + | t.Parallel() | |||
| 47 | + | ||||
| 48 | + | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatalf("readAssetWithEncoding() error = %v", err) | |||
| 51 | + | } | |||
| 52 | + | if len(data) < 4 { | |||
| 53 | + | t.Fatalf("decompressed wasm too short: got %d bytes", len(data)) | |||
| 54 | + | } | |||
| 55 | + | if string(data[:4]) != "\x00asm" { | |||
| 56 | + | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func mustParseURL(t *testing.T, raw string) *url.URL { | |||
| 61 | + | t.Helper() | |||
| 62 | + | u, err := url.Parse(raw) | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatalf("url.Parse(%q) error = %v", raw, err) | |||
| 65 | + | } | |||
| 66 | + | return u | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) { | |||
| 70 | + | t.Parallel() | |||
| 71 | + | ||||
| 72 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css") | |||
| 73 | + | if !ok { | |||
| 74 | + | t.Fatal("safeSystemviewsAssetPath() rejected safe cleaned path") | |||
| 75 | + | } | |||
| 76 | + | if fullPath != "systemviews/systemviews.css" || relPath != "systemviews.css" { | |||
| 77 | + | t.Fatalf("safeSystemviewsAssetPath() = (%q, %q), want (systemviews/systemviews.css, systemviews.css)", fullPath, relPath) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | invalid := []struct { | |||
| 81 | + | name string | |||
| 82 | + | assetDir string | |||
| 83 | + | relPath string | |||
| 84 | + | }{ | |||
| 85 | + | {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"}, | |||
| 86 | + | {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"}, | |||
| 87 | + | {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"}, | |||
| 88 | + | {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"}, | |||
| 89 | + | {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"}, | |||
| 90 | + | } | |||
| 91 | + | for _, tt := range invalid { | |||
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | + | t.Parallel() | |||
| 94 | + | if fullPath, relPath, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath); ok { | |||
| 95 | + | t.Fatalf("safeSystemviewsAssetPath(%q, %q) = (%q, %q, true), want rejected", tt.assetDir, tt.relPath, fullPath, relPath) | |||
| 96 | + | } | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | } | |||
M internal/infrastructure/cef/scheme_handler_test.go +272 -0
| @@ -4,7 +4,9 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "net" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 9 | + | "net/url" | |||
| 8 | "testing" | 10 | "testing" | ||
| 9 | 11 | ||||
| 10 | purecef "github.com/bnema/purego-cef/cef" | 12 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -24,6 +26,31 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) { | |||||
| 24 | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | 26 | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | ||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 29 | + | func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) { | |||
| 30 | + | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type panicFaviconService struct{} | |||
| 34 | + | ||||
| 35 | + | func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) { | |||
| 36 | + | panic("unexpected favicon lookup") | |||
| 37 | + | } | |||
| 38 | + | func (panicFaviconService) Get(context.Context, string) ([]byte, error) { | |||
| 39 | + | panic("unexpected favicon lookup") | |||
| 40 | + | } | |||
| 41 | + | func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") } | |||
| 42 | + | func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") } | |||
| 43 | + | func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") } | |||
| 44 | + | func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") } | |||
| 45 | + | func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error { | |||
| 46 | + | panic("unexpected favicon lookup") | |||
| 47 | + | } | |||
| 48 | + | func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") } | |||
| 49 | + | func (panicFaviconService) EnsureDiskCache(context.Context, string) { | |||
| 50 | + | panic("unexpected favicon lookup") | |||
| 51 | + | } | |||
| 52 | + | func (panicFaviconService) Close() {} | |||
| 53 | + | ||||
| 27 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | 54 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | ||
| 28 | data, err := resolveConfigPayload(func() ([]byte, error) { | 55 | data, err := resolveConfigPayload(func() ([]byte, error) { | ||
| 29 | return []byte(`{"engine_type":"cef"}`), nil | 56 | return []byte(`{"engine_type":"cef"}`), nil | ||
| @@ -60,6 +87,251 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||||
| 60 | require.Contains(t, err.Error(), "default config payload builder") | 87 | require.Contains(t, err.Error(), "default config payload builder") | ||
| 61 | } | 88 | } | ||
| 62 | 89 | ||||
| 90 | + | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | |||
| 91 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | ||||
| 94 | + | request := cefmocks.NewMockRequest(t) | |||
| 95 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 96 | + | ||||
| 97 | + | require.Nil(t, h.rejectForbiddenAPIOrigin(request)) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||
| 101 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 102 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 103 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 104 | + | ||||
| 105 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | ||||
| 108 | + | request := cefmocks.NewMockRequest(t) | |||
| 109 | + | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |||
| 110 | + | ||||
| 111 | + | response := cefmocks.NewMockResponse(t) | |||
| 112 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 113 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 114 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 115 | + | expectAPIResponseHeaders(response) | |||
| 116 | + | ||||
| 117 | + | handler := h.rejectForbiddenAPIOrigin(request) | |||
| 118 | + | require.NotNil(t, handler) | |||
| 119 | + | var responseLength int64 | |||
| 120 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |||
| 121 | + | require.Positive(t, responseLength) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testing.T) { | |||
| 125 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 126 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 127 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 128 | + | ||||
| 129 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | ||||
| 132 | + | trustedOrigin := cefmocks.NewMockRequest(t) | |||
| 133 | + | trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 134 | + | require.Nil(t, h.rejectUntrustedConfigRequester(trustedOrigin)) | |||
| 135 | + | ||||
| 136 | + | trustedReferrer := cefmocks.NewMockRequest(t) | |||
| 137 | + | trustedReferrer.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 138 | + | trustedReferrer.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |||
| 139 | + | require.Nil(t, h.rejectUntrustedConfigRequester(trustedReferrer)) | |||
| 140 | + | ||||
| 141 | + | emptyContext := cefmocks.NewMockRequest(t) | |||
| 142 | + | emptyContext.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 143 | + | emptyContext.EXPECT().GetReferrerURL().Return("").Once() | |||
| 144 | + | emptyContext.EXPECT().GetHeaderByName("Referer").Return("").Once() | |||
| 145 | + | denied := h.rejectUntrustedConfigRequester(emptyContext) | |||
| 146 | + | require.NotNil(t, denied) | |||
| 147 | + | ||||
| 148 | + | response := cefmocks.NewMockResponse(t) | |||
| 149 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 150 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 151 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 152 | + | expectPrivateAPIResponseHeaders(response) | |||
| 153 | + | var responseLength int64 | |||
| 154 | + | denied.GetResponseHeaders(response, &responseLength, 0) | |||
| 155 | + | require.Positive(t, responseLength) | |||
| 156 | + | ||||
| 157 | + | untrustedOrigin := cefmocks.NewMockRequest(t) | |||
| 158 | + | untrustedOrigin.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |||
| 159 | + | require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin)) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) { | |||
| 163 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 164 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 165 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 166 | + | ||||
| 167 | + | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | ||||
| 170 | + | trusted := cefmocks.NewMockRequest(t) | |||
| 171 | + | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 172 | + | trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |||
| 173 | + | trusted.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(base64.StdEncoding.EncodeToString([]byte(`{"type":"missing"}`))).Once() | |||
| 174 | + | trustedHandler := h.handleMessageAPI(trusted) | |||
| 175 | + | require.NotNil(t, trustedHandler) | |||
| 176 | + | ||||
| 177 | + | untrusted := cefmocks.NewMockRequest(t) | |||
| 178 | + | untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 179 | + | untrusted.EXPECT().GetReferrerURL().Return("").Once() | |||
| 180 | + | untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once() | |||
| 181 | + | denied := h.handleMessageAPI(untrusted) | |||
| 182 | + | require.NotNil(t, denied) | |||
| 183 | + | ||||
| 184 | + | response := cefmocks.NewMockResponse(t) | |||
| 185 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 186 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 187 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 188 | + | expectPrivateAPIResponseHeaders(response) | |||
| 189 | + | var responseLength int64 | |||
| 190 | + | denied.GetResponseHeaders(response, &responseLength, 0) | |||
| 191 | + | require.Positive(t, responseLength) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 195 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 196 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 197 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 198 | + | ||||
| 199 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | ||||
| 202 | + | response := cefmocks.NewMockResponse(t) | |||
| 203 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |||
| 204 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |||
| 205 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 206 | + | expectPrivateAPIResponseHeaders(response) | |||
| 207 | + | ||||
| 208 | + | handler := h.handleConfigAPI(h.currentConfigPayload) | |||
| 209 | + | var responseLength int64 | |||
| 210 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |||
| 211 | + | require.Positive(t, responseLength) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |||
| 215 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 216 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 217 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 218 | + | ||||
| 219 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 220 | + | require.NoError(t, err) | |||
| 221 | + | ||||
| 222 | + | request := cefmocks.NewMockRequest(t) | |||
| 223 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 224 | + | ||||
| 225 | + | response := cefmocks.NewMockResponse(t) | |||
| 226 | + | response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once() | |||
| 227 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once() | |||
| 228 | + | response.EXPECT().SetMimeType("text/plain").Once() | |||
| 229 | + | response.EXPECT().SetCharset("utf-8").Once() | |||
| 230 | + | expectPrivateAPIResponseHeaders(response) | |||
| 231 | + | ||||
| 232 | + | handler := h.handleAPI(nil, http.MethodOptions, "/api/config", request) | |||
| 233 | + | var responseLength int64 | |||
| 234 | + | handler.GetResponseHeaders(response, &responseLength, 0) | |||
| 235 | + | require.Zero(t, responseLength) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestIsTrustedSystemviewURL(t *testing.T) { | |||
| 239 | + | t.Parallel() | |||
| 240 | + | ||||
| 241 | + | for _, raw := range []string{ | |||
| 242 | + | "https://dumber.invalid/history", | |||
| 243 | + | "dumb://history", | |||
| 244 | + | "dumb:history", | |||
| 245 | + | } { | |||
| 246 | + | require.True(t, isTrustedSystemviewURL(raw), raw) | |||
| 247 | + | } | |||
| 248 | + | for _, raw := range []string{ | |||
| 249 | + | "", | |||
| 250 | + | "https://evil.example/history", | |||
| 251 | + | "dumb://api/favicon", | |||
| 252 | + | "dumb://evil/history", | |||
| 253 | + | } { | |||
| 254 | + | require.False(t, isTrustedSystemviewURL(raw), raw) | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | |||
| 259 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 260 | + | require.NoError(t, err) | |||
| 261 | + | ||||
| 262 | + | request := cefmocks.NewMockRequest(t) | |||
| 263 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 264 | + | ||||
| 265 | + | require.Nil(t, h.rejectUntrustedFaviconRequester(request)) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedReferrer(t *testing.T) { | |||
| 269 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 270 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 271 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 272 | + | ||||
| 273 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | ||||
| 276 | + | request := cefmocks.NewMockRequest(t) | |||
| 277 | + | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | |||
| 278 | + | ||||
| 279 | + | require.NotNil(t, h.rejectUntrustedFaviconRequester(request)) | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(t *testing.T) { | |||
| 283 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 284 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 285 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 286 | + | ||||
| 287 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 288 | + | require.NoError(t, err) | |||
| 289 | + | ||||
| 290 | + | trusted := cefmocks.NewMockRequest(t) | |||
| 291 | + | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 292 | + | trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once() | |||
| 293 | + | require.Nil(t, h.rejectUntrustedFaviconRequester(trusted)) | |||
| 294 | + | ||||
| 295 | + | untrusted := cefmocks.NewMockRequest(t) | |||
| 296 | + | untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | |||
| 297 | + | untrusted.EXPECT().GetReferrerURL().Return("").Once() | |||
| 298 | + | untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once() | |||
| 299 | + | require.NotNil(t, h.rejectUntrustedFaviconRequester(untrusted)) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) { | |||
| 303 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 304 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 305 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 306 | + | ||||
| 307 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 308 | + | require.NoError(t, err) | |||
| 309 | + | h.setFaviconService(panicFaviconService{}) | |||
| 310 | + | ||||
| 311 | + | request := cefmocks.NewMockRequest(t) | |||
| 312 | + | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | |||
| 313 | + | request.EXPECT().GetURL().Return(actualInternalOrigin + "/api/favicon?domain=example.com&size=32").Once() | |||
| 314 | + | ||||
| 315 | + | require.NotNil(t, h.handleFaviconAPI(request)) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |||
| 319 | + | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 320 | + | require.NoError(t, err) | |||
| 321 | + | ||||
| 322 | + | err = validateTranscodeSourceURL(context.Background(), privateURL) | |||
| 323 | + | require.Error(t, err) | |||
| 324 | + | require.Contains(t, err.Error(), "private src host") | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 328 | + | for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} { | |||
| 329 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 330 | + | require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP))) | |||
| 331 | + | }) | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 63 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | 335 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | ||
| 64 | request := cefmocks.NewMockRequest(t) | 336 | request := cefmocks.NewMockRequest(t) | ||
| 65 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | 337 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | ||
M internal/infrastructure/cef/transcoding_handler.go +89 -17
| @@ -24,6 +24,8 @@ var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | |||||
| 24 | const ( | 24 | const ( | ||
| 25 | maxTranscodingURLLength = 240 | 25 | maxTranscodingURLLength = 240 | ||
| 26 | httpStatusOK = 200 | 26 | httpStatusOK = 200 | ||
| 27 | + | httpStatusForbidden = 403 | |||
| 28 | + | httpStatusBadGateway = 502 | |||
| 27 | ) | 29 | ) | ||
| 28 | 30 | ||||
| 29 | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | 31 | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | ||
| @@ -34,14 +36,18 @@ const ( | |||||
| 34 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- | ||
| 35 | 37 | ||||
| 36 | type transcodingResourceHandler struct { | 38 | type transcodingResourceHandler struct { | ||
| 37 | - | transcoder port.MediaTranscoder | 39 | + | transcoder port.MediaTranscoder |
| 38 | - | sourceURL string | 40 | + | sourceURL string |
| 39 | - | headers map[string]string | 41 | + | headers map[string]string |
| 40 | - | session port.TranscodeSession | 42 | + | session port.TranscodeSession |
| 41 | - | ctx context.Context | 43 | + | ctx context.Context |
| 42 | - | cancel context.CancelFunc | 44 | + | cancel context.CancelFunc |
| 43 | - | logf func() zerolog.Logger | 45 | + | logf func() zerolog.Logger |
| 44 | - | totalBytes int64 | 46 | + | validateSource func(context.Context) error |
| 47 | + | mu sync.Mutex | |||
| 48 | + | startErr error | |||
| 49 | + | startErrStatus int | |||
| 50 | + | totalBytes int64 | |||
| 45 | } | 51 | } | ||
| 46 | 52 | ||||
| 47 | // Open starts the transcode session asynchronously. CEF will call | 53 | // Open starts the transcode session asynchronously. CEF will call | ||
| @@ -62,6 +68,19 @@ func (rh *transcodingResourceHandler) Open( | |||||
| 62 | Msg("cef: starting transcoding resource stream") | 68 | Msg("cef: starting transcoding resource stream") | ||
| 63 | 69 | ||||
| 64 | go func() { | 70 | go func() { | ||
| 71 | + | if rh.validateSource != nil { | |||
| 72 | + | if err := rh.validateSource(rh.ctx); err != nil { | |||
| 73 | + | log.Warn(). | |||
| 74 | + | Err(err). | |||
| 75 | + | Str("source_url", sourceURL). | |||
| 76 | + | Msg("cef: rejected transcoding source") | |||
| 77 | + | rh.setStartErr(err, httpStatusForbidden) | |||
| 78 | + | rh.cancel() | |||
| 79 | + | callback.Cont() | |||
| 80 | + | return | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 65 | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | 84 | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | ||
| 66 | if err != nil { | 85 | if err != nil { | ||
| 67 | log.Error(). | 86 | log.Error(). | ||
| @@ -69,11 +88,14 @@ func (rh *transcodingResourceHandler) Open( | |||||
| 69 | Str("source_url", sourceURL). | 88 | Str("source_url", sourceURL). | ||
| 70 | Msg("cef: failed to start transcoding resource stream") | 89 | Msg("cef: failed to start transcoding resource stream") | ||
| 71 | // Let CEF know we failed — Cancel will be called. | 90 | // Let CEF know we failed — Cancel will be called. | ||
| 91 | + | rh.setStartErr(err, httpStatusBadGateway) | |||
| 72 | rh.cancel() | 92 | rh.cancel() | ||
| 73 | callback.Cont() | 93 | callback.Cont() | ||
| 74 | return | 94 | return | ||
| 75 | } | 95 | } | ||
| 96 | + | rh.mu.Lock() | |||
| 76 | rh.session = session | 97 | rh.session = session | ||
| 98 | + | rh.mu.Unlock() | |||
| 77 | log.Info(). | 99 | log.Info(). | ||
| 78 | Str("source_url", sourceURL). | 100 | Str("source_url", sourceURL). | ||
| 79 | Str("content_type", session.ContentType()). | 101 | Str("content_type", session.ContentType()). | ||
| @@ -84,10 +106,52 @@ func (rh *transcodingResourceHandler) Open( | |||||
| 84 | return 1 | 106 | return 1 | ||
| 85 | } | 107 | } | ||
| 86 | 108 | ||||
| 109 | + | func (rh *transcodingResourceHandler) setStartErr(err error, status int) { | |||
| 110 | + | rh.mu.Lock() | |||
| 111 | + | defer rh.mu.Unlock() | |||
| 112 | + | rh.startErr = err | |||
| 113 | + | rh.startErrStatus = status | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |||
| 117 | + | rh.mu.Lock() | |||
| 118 | + | defer rh.mu.Unlock() | |||
| 119 | + | return rh.startErrStatus, rh.startErr | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 { | |||
| 123 | + | rh.mu.Lock() | |||
| 124 | + | defer rh.mu.Unlock() | |||
| 125 | + | rh.totalBytes += int64(n) | |||
| 126 | + | return rh.totalBytes | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (rh *transcodingResourceHandler) getTotalBytes() int64 { | |||
| 130 | + | rh.mu.Lock() | |||
| 131 | + | defer rh.mu.Unlock() | |||
| 132 | + | return rh.totalBytes | |||
| 133 | + | } | |||
| 134 | + | ||||
| 87 | // GetResponseHeaders sets the streaming response metadata. | 135 | // GetResponseHeaders sets the streaming response metadata. | ||
| 88 | func (rh *transcodingResourceHandler) GetResponseHeaders( | 136 | func (rh *transcodingResourceHandler) GetResponseHeaders( | ||
| 89 | response purecef.Response, responseLength *int64, _ uintptr, | 137 | response purecef.Response, responseLength *int64, _ uintptr, | ||
| 90 | ) { | 138 | ) { | ||
| 139 | + | if status, startErr := rh.getStartErr(); startErr != nil { | |||
| 140 | + | if status == 0 { | |||
| 141 | + | status = httpStatusBadGateway | |||
| 142 | + | } | |||
| 143 | + | response.SetStatus(int32(status)) | |||
| 144 | + | if status == httpStatusForbidden { | |||
| 145 | + | response.SetStatusText("Forbidden") | |||
| 146 | + | } else { | |||
| 147 | + | response.SetStatusText("Bad Gateway") | |||
| 148 | + | } | |||
| 149 | + | response.SetMimeType("text/plain") | |||
| 150 | + | if responseLength != nil { | |||
| 151 | + | *responseLength = 0 | |||
| 152 | + | } | |||
| 153 | + | return | |||
| 154 | + | } | |||
| 91 | response.SetStatus(httpStatusOK) | 155 | response.SetStatus(httpStatusOK) | ||
| 92 | response.SetStatusText("OK") | 156 | response.SetStatusText("OK") | ||
| 93 | response.SetMimeType("video/webm") | 157 | response.SetMimeType("video/webm") | ||
| @@ -108,15 +172,18 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 108 | dataOut unsafe.Pointer, bytesToRead int32, | 172 | dataOut unsafe.Pointer, bytesToRead int32, | ||
| 109 | bytesRead *int32, _ purecef.ResourceReadCallback, | 173 | bytesRead *int32, _ purecef.ResourceReadCallback, | ||
| 110 | ) int32 { | 174 | ) int32 { | ||
| 111 | - | if rh.session == nil { | 175 | + | rh.mu.Lock() |
| 176 | + | session := rh.session | |||
| 177 | + | rh.mu.Unlock() | |||
| 178 | + | if session == nil { | |||
| 112 | return 0 | 179 | return 0 | ||
| 113 | } | 180 | } | ||
| 114 | 181 | ||||
| 115 | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | 182 | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | ||
| 116 | - | n, err := rh.session.Read(dst) | 183 | + | n, err := session.Read(dst) |
| 117 | if n > 0 { | 184 | if n > 0 { | ||
| 118 | - | rh.totalBytes += int64(n) | 185 | + | totalBytes := rh.addTotalBytes(n) |
| 119 | - | if rh.totalBytes == int64(n) { | 186 | + | if totalBytes == int64(n) { |
| 120 | rh.logger().Info(). | 187 | rh.logger().Info(). | ||
| 121 | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | 188 | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | ||
| 122 | Int("first_chunk_bytes", n). | 189 | Int("first_chunk_bytes", n). | ||
| @@ -128,13 +195,14 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 128 | return 1 | 195 | return 1 | ||
| 129 | } | 196 | } | ||
| 130 | if err != nil { | 197 | if err != nil { | ||
| 198 | + | totalBytes := rh.getTotalBytes() | |||
| 131 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | 199 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | ||
| 132 | log := rh.logger().With(). | 200 | log := rh.logger().With(). | ||
| 133 | Str("source_url", sourceURL). | 201 | Str("source_url", sourceURL). | ||
| 134 | - | Int64("total_bytes", rh.totalBytes). | 202 | + | Int64("total_bytes", totalBytes). |
| 135 | Logger() | 203 | Logger() | ||
| 136 | switch { | 204 | switch { | ||
| 137 | - | case errors.Is(err, io.EOF) && rh.totalBytes == 0: | 205 | + | case errors.Is(err, io.EOF) && totalBytes == 0: |
| 138 | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | 206 | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | ||
| 139 | case errors.Is(err, io.EOF): | 207 | case errors.Is(err, io.EOF): | ||
| 140 | log.Debug().Msg("cef: transcoding resource stream reached EOF") | 208 | log.Debug().Msg("cef: transcoding resource stream reached EOF") | ||
| @@ -149,12 +217,16 @@ func (rh *transcodingResourceHandler) Read( | |||||
| 149 | // Cancel aborts the transcode session and releases resources. | 217 | // Cancel aborts the transcode session and releases resources. | ||
| 150 | func (rh *transcodingResourceHandler) Cancel() { | 218 | func (rh *transcodingResourceHandler) Cancel() { | ||
| 151 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | 219 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | ||
| 220 | + | totalBytes := rh.getTotalBytes() | |||
| 152 | rh.logger().Debug(). | 221 | rh.logger().Debug(). | ||
| 153 | Str("source_url", sourceURL). | 222 | Str("source_url", sourceURL). | ||
| 154 | - | Int64("total_bytes", rh.totalBytes). | 223 | + | Int64("total_bytes", totalBytes). |
| 155 | Msg("cef: transcoding resource stream canceled") | 224 | Msg("cef: transcoding resource stream canceled") | ||
| 156 | - | if rh.session != nil { | 225 | + | rh.mu.Lock() |
| 157 | - | if err := rh.session.Close(); err != nil { | 226 | + | session := rh.session |
| 227 | + | rh.mu.Unlock() | |||
| 228 | + | if session != nil { | |||
| 229 | + | if err := session.Close(); err != nil { | |||
| 158 | rh.logger().Warn(). | 230 | rh.logger().Warn(). | ||
| 159 | Str("source_url", sourceURL). | 231 | Str("source_url", sourceURL). | ||
| 160 | Err(err). | 232 | Err(err). | ||
M internal/infrastructure/config/defaults.go +11 -8
| @@ -240,14 +240,17 @@ func DefaultConfig() *Config { | |||||
| 240 | }, | 240 | }, | ||
| 241 | Shortcuts: GlobalShortcutsConfig{ | 241 | Shortcuts: GlobalShortcutsConfig{ | ||
| 242 | Actions: map[string]ActionBinding{ | 242 | Actions: map[string]ActionBinding{ | ||
| 243 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | 243 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, |
| 244 | - | "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, | 244 | + | "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, |
| 245 | - | "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, | 245 | + | "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"}, |
| 246 | - | "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, | 246 | + | "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"}, |
| 247 | - | "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, | 247 | + | "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"}, |
| 248 | - | "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | 248 | + | "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"}, |
| 249 | - | "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | 249 | + | "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"}, |
| 250 | - | "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | 250 | + | "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"}, |
| 251 | + | "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | |||
| 252 | + | "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | |||
| 253 | + | "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | |||
| 251 | }, | 254 | }, | ||
| 252 | }, | 255 | }, | ||
| 253 | FloatingPane: FloatingPaneConfig{ | 256 | FloatingPane: FloatingPaneConfig{ | ||
M internal/infrastructure/config/defaults_test.go +17 -0
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/assert" | 6 | "github.com/stretchr/testify/assert" | ||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | func TestDefaultConfig_CoreDefaults(t *testing.T) { | 10 | func TestDefaultConfig_CoreDefaults(t *testing.T) { | ||
| @@ -27,6 +28,22 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 27 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | 28 | assert.False(t, cfg.Engine.CEF.TraceHandlers) | ||
| 28 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | 29 | assert.True(t, cfg.Engine.WebKit.ITPEnabled) | ||
| 29 | 30 | ||||
| 31 | + | // System view shortcuts are first-class global actions. | |||
| 32 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"}) | |||
| 33 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{}) | |||
| 34 | + | requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{}) | |||
| 35 | + | ||||
| 30 | // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config. | 36 | // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config. | ||
| 31 | // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above). | 37 | // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above). | ||
| 32 | } | 38 | } | ||
| 39 | + | ||||
| 40 | + | func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) { | |||
| 41 | + | t.Helper() | |||
| 42 | + | binding, ok := actions[action] | |||
| 43 | + | require.True(t, ok, "missing action binding %q", action) | |||
| 44 | + | if len(keys) == 0 { | |||
| 45 | + | assert.Empty(t, binding.Keys) | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | assert.Equal(t, keys, binding.Keys) | |||
| 49 | + | } | |||
M internal/infrastructure/config/floating_pane_config_test.go +2 -2
| @@ -15,7 +15,7 @@ func TestFloatingPaneConfig_Defaults(t *testing.T) { | |||||
| 15 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | 15 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | ||
| 16 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | 16 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | ||
| 17 | 17 | ||||
| 18 | - | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"] | 18 | + | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"] |
| 19 | require.True(t, ok) | 19 | require.True(t, ok) | ||
| 20 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | 20 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | ||
| 21 | } | 21 | } | ||
| @@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) { | |||||
| 31 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | 31 | assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001) | ||
| 32 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | 32 | assert.Empty(t, cfg.Workspace.FloatingPane.Profiles) | ||
| 33 | 33 | ||||
| 34 | - | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"] | 34 | + | binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"] |
| 35 | require.True(t, ok) | 35 | require.True(t, ok) | ||
| 36 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | 36 | assert.Equal(t, []string{"alt+f"}, binding.Keys) | ||
| 37 | } | 37 | } | ||
M internal/infrastructure/config/loader.go +2 -0
| @@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) { | |||||
| 254 | config.Engine.Profile = ProfileDefault | 254 | config.Engine.Profile = ProfileDefault | ||
| 255 | case string(ProfileLite): | 255 | case string(ProfileLite): | ||
| 256 | config.Engine.Profile = ProfileLite | 256 | config.Engine.Profile = ProfileLite | ||
| 257 | + | case string(ProfileBalanced): | |||
| 258 | + | config.Engine.Profile = ProfileBalanced | |||
| 257 | case string(ProfileMax): | 259 | case string(ProfileMax): | ||
| 258 | config.Engine.Profile = ProfileMax | 260 | config.Engine.Profile = ProfileMax | ||
| 259 | case string(ProfileCustom): | 261 | case string(ProfileCustom): | ||
M internal/infrastructure/config/migrate.go +8 -0
| @@ -777,6 +777,10 @@ func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[ | |||||
| 777 | if _, exists := userActions[actionName]; exists { | 777 | if _, exists := userActions[actionName]; exists { | ||
| 778 | continue | 778 | continue | ||
| 779 | } | 779 | } | ||
| 780 | + | legacyActionName := strings.ReplaceAll(actionName, "-", "_") | |||
| 781 | + | if _, exists := userActions[legacyActionName]; exists { | |||
| 782 | + | continue | |||
| 783 | + | } | |||
| 780 | missing = append(missing, workspaceShortcutsActionsKey+"."+actionName) | 784 | missing = append(missing, workspaceShortcutsActionsKey+"."+actionName) | ||
| 781 | } | 785 | } | ||
| 782 | 786 | ||||
| @@ -801,6 +805,10 @@ func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any | |||||
| 801 | if _, exists := userActions[actionName]; exists { | 805 | if _, exists := userActions[actionName]; exists { | ||
| 802 | continue | 806 | continue | ||
| 803 | } | 807 | } | ||
| 808 | + | legacyActionName := strings.ReplaceAll(actionName, "-", "_") | |||
| 809 | + | if _, exists := userActions[legacyActionName]; exists { | |||
| 810 | + | continue | |||
| 811 | + | } | |||
| 804 | userActions[actionName] = defaultAction | 812 | userActions[actionName] = defaultAction | ||
| 805 | } | 813 | } | ||
| 806 | 814 | ||||
M internal/infrastructure/config/migrate_test.go +11 -4
| @@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) { | |||||
| 695 | for actionName, value := range defaultActions { | 695 | for actionName, value := range defaultActions { | ||
| 696 | userActions[actionName] = 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 | ||||
| @@ -759,12 +759,19 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) { | |||||
| 759 | closePane, ok := closePaneAny.(map[string]any) | 759 | closePane, ok := closePaneAny.(map[string]any) | ||
| 760 | require.True(t, ok) | 760 | require.True(t, ok) | ||
| 761 | assert.Equal(t, "Custom close pane", closePane["desc"]) | 761 | assert.Equal(t, "Custom close pane", closePane["desc"]) | ||
| 762 | + | assert.NotContains(t, actions, "close-pane") | |||
| 763 | + | assert.NotContains(t, actions, "next-tab") | |||
| 764 | + | assert.NotContains(t, actions, "previous-tab") | |||
| 765 | + | assert.NotContains(t, actions, "consume-or-expel-left") | |||
| 766 | + | assert.NotContains(t, actions, "consume-or-expel-right") | |||
| 767 | + | assert.NotContains(t, actions, "consume-or-expel-up") | |||
| 768 | + | assert.NotContains(t, actions, "consume-or-expel-down") | |||
| 762 | } | 769 | } | ||
| 763 | 770 | ||||
| 764 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | 771 | func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) { | ||
| 765 | m := NewMigrator() | 772 | m := NewMigrator() | ||
| 766 | 773 | ||||
| 767 | - | value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane") | 774 | + | value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane") |
| 768 | 775 | ||||
| 769 | action, ok := value.(ActionBinding) | 776 | action, ok := value.(ActionBinding) | ||
| 770 | require.True(t, ok) | 777 | require.True(t, ok) | ||
M internal/infrastructure/config/profile.go +9 -1
| @@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re | |||||
| 85 | switch cfg.Profile { | 85 | switch cfg.Profile { | ||
| 86 | case ProfileLite: | 86 | case ProfileLite: | ||
| 87 | return resolveLiteProfile(hw) | 87 | return resolveLiteProfile(hw) | ||
| 88 | + | case ProfileBalanced: | |||
| 89 | + | return resolveBalancedProfile() | |||
| 88 | case ProfileMax: | 90 | case ProfileMax: | ||
| 89 | return resolveMaxProfile(hw) | 91 | return resolveMaxProfile(hw) | ||
| 90 | case ProfileCustom: | 92 | case ProfileCustom: | ||
| @@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings { | |||||
| 116 | } | 118 | } | ||
| 117 | } | 119 | } | ||
| 118 | 120 | ||||
| 121 | + | // resolveBalancedProfile preserves the legacy balanced profile as WebKit's | |||
| 122 | + | // default tuning with the normal WebView prewarm count. | |||
| 123 | + | func resolveBalancedProfile() ResolvedPerformanceSettings { | |||
| 124 | + | return resolveDefaultProfile() | |||
| 125 | + | } | |||
| 126 | + | ||||
| 119 | // resolveLiteProfile returns settings optimized for low-RAM systems. | 127 | // resolveLiteProfile returns settings optimized for low-RAM systems. | ||
| 120 | // Uses hardware info to scale CPU threads if available. | 128 | // Uses hardware info to scale CPU threads if available. | ||
| 121 | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | 129 | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | ||
| @@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool { | |||||
| 299 | // IsValidPerformanceProfile returns true if the profile name is recognized. | 307 | // IsValidPerformanceProfile returns true if the profile name is recognized. | ||
| 300 | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | 308 | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | ||
| 301 | switch profile { | 309 | switch profile { | ||
| 302 | - | case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "": | 310 | + | case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "": |
| 303 | return true | 311 | return true | ||
| 304 | default: | 312 | default: | ||
| 305 | return false | 313 | return false | ||
M internal/infrastructure/config/profile_test.go +17 -0
| @@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func TestResolvePerformanceProfile_Balanced(t *testing.T) { | |||
| 48 | + | cfg := &PerformanceConfig{Profile: ProfileBalanced} | |||
| 49 | + | ||||
| 50 | + | result := ResolvePerformanceProfile(cfg, nil) | |||
| 51 | + | ||||
| 52 | + | if result.SkiaCPUPaintingThreads != 0 { | |||
| 53 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |||
| 54 | + | } | |||
| 55 | + | if result.SkiaGPUPaintingThreads != -1 { | |||
| 56 | + | t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads) | |||
| 57 | + | } | |||
| 58 | + | if result.WebViewPoolPrewarmCount != 4 { | |||
| 59 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 47 | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | 63 | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | ||
| 48 | cfg := &PerformanceConfig{ | 64 | cfg := &PerformanceConfig{ | ||
| 49 | Profile: ProfileLite, | 65 | Profile: ProfileLite, | ||
| @@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) { | |||||
| 317 | }{ | 333 | }{ | ||
| 318 | {ProfileDefault, true}, | 334 | {ProfileDefault, true}, | ||
| 319 | {ProfileLite, true}, | 335 | {ProfileLite, true}, | ||
| 336 | + | {ProfileBalanced, true}, | |||
| 320 | {ProfileMax, true}, | 337 | {ProfileMax, true}, | ||
| 321 | {ProfileCustom, true}, | 338 | {ProfileCustom, true}, | ||
| 322 | {"", true}, // Empty is valid (treated as default) | 339 | {"", true}, // Empty is valid (treated as default) | ||
M internal/infrastructure/config/schema.go +3 -1
| @@ -170,6 +170,8 @@ const ( | |||||
| 170 | ProfileDefault PerformanceProfile = "default" | 170 | ProfileDefault PerformanceProfile = "default" | ||
| 171 | // ProfileLite reduces resource usage for low-RAM systems. | 171 | // ProfileLite reduces resource usage for low-RAM systems. | ||
| 172 | ProfileLite PerformanceProfile = "lite" | 172 | ProfileLite PerformanceProfile = "lite" | ||
| 173 | + | // ProfileBalanced is a conservative compatibility preset between lite and max. | |||
| 174 | + | ProfileBalanced PerformanceProfile = "balanced" | |||
| 173 | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | 175 | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | ||
| 174 | ProfileMax PerformanceProfile = "max" | 176 | ProfileMax PerformanceProfile = "max" | ||
| 175 | // ProfileCustom allows full control over individual performance settings. | 177 | // ProfileCustom allows full control over individual performance settings. | ||
| @@ -226,7 +228,7 @@ type HistoryConfig struct { | |||||
| 226 | // These are not exposed in dumb://config UI but can be set in config file. | 228 | // These are not exposed in dumb://config UI but can be set in config file. | ||
| 227 | type PerformanceConfig struct { | 229 | type PerformanceConfig struct { | ||
| 228 | // Profile selects a preset performance configuration. | 230 | // Profile selects a preset performance configuration. | ||
| 229 | - | // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control) | 231 | + | // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control) |
| 230 | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | 232 | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | ||
| 231 | // Default: "default" | 233 | // Default: "default" | ||
| 232 | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | 234 | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | ||
M internal/infrastructure/config/schema_provider.go +1 -1
| @@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 905 | Type: "string", | 905 | Type: "string", | ||
| 906 | Default: string(defaults.Engine.Profile), | 906 | Default: string(defaults.Engine.Profile), | ||
| 907 | Description: "Performance profile preset (custom enables manual tuning)", | 907 | Description: "Performance profile preset (custom enables manual tuning)", | ||
| 908 | - | Values: []string{"default", "lite", "max", "custom"}, | 908 | + | Values: []string{"default", "lite", "balanced", "max", "custom"}, |
| 909 | Section: SectionPerformance, | 909 | Section: SectionPerformance, | ||
| 910 | }, | 910 | }, | ||
| 911 | { | 911 | { | ||
A internal/infrastructure/config/systemview_reader.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type systemviewConfigReader struct { | |||
| 11 | + | hwSurveyor port.HardwareSurveyor | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | var _ port.SystemviewConfigReader = (*systemviewConfigReader)(nil) | |||
| 15 | + | ||||
| 16 | + | // NewSystemviewConfigReader creates a read-side config payload reader. | |||
| 17 | + | func NewSystemviewConfigReader(hwSurveyor port.HardwareSurveyor) port.SystemviewConfigReader { | |||
| 18 | + | return &systemviewConfigReader{hwSurveyor: hwSurveyor} | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (r *systemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 22 | + | return r.build(ctx, Get()) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (r *systemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 26 | + | return r.build(ctx, DefaultConfig()) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (dto.SystemviewConfigPayload, error) { | |||
| 30 | + | if cfg == nil { | |||
| 31 | + | return dto.SystemviewConfigPayload{}, nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | var hw *port.HardwareInfo | |||
| 35 | + | if r.hwSurveyor != nil { | |||
| 36 | + | survey := r.hwSurveyor.Survey(ctx) | |||
| 37 | + | hw = &survey | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | return BuildSystemviewConfigPayload(cfg, hw), nil | |||
| 41 | + | } | |||
M internal/infrastructure/config/validation.go +1 -1
| @@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string { | |||||
| 483 | // Validate profile name | 483 | // Validate profile name | ||
| 484 | if !IsValidPerformanceProfile(config.Engine.Profile) { | 484 | if !IsValidPerformanceProfile(config.Engine.Profile) { | ||
| 485 | validationErrors = append(validationErrors, fmt.Sprintf( | 485 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 486 | - | "engine.profile must be one of: default, lite, max, custom (got: %s)", | 486 | + | "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)", |
| 487 | config.Engine.Profile, | 487 | config.Engine.Profile, | ||
| 488 | )) | 488 | )) | ||
| 489 | } | 489 | } | ||
M internal/infrastructure/config/webui_gateway.go +2 -2
| @@ -4,7 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/dumber/internal/application/port" | 7 | + | "github.com/bnema/dumber/internal/application/dto" |
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | type WebUIConfigGateway struct { | 10 | type WebUIConfigGateway struct { | ||
| @@ -33,7 +33,7 @@ func clampInt(v, lo, hi int) int { | |||||
| 33 | return v | 33 | return v | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | - | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error { | 36 | + | func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error { |
| 37 | _ = ctx | 37 | _ = ctx | ||
| 38 | if g == nil || g.mgr == nil { | 38 | if g == nil || g.mgr == nil { | ||
| 39 | return fmt.Errorf("config manager not initialized") | 39 | return fmt.Errorf("config manager not initialized") | ||
M internal/infrastructure/config/webui_payload.go +78 -95
| @@ -1,118 +1,101 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/dumber/internal/application/port" | 3 | + | import ( |
| 4 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 5 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | - | // WebUIConfigAppearancePayload is the JSON shape expected by dumb://config. | 8 | + | // BuildSystemviewConfigPayload projects the full config into the stable DTO used by |
| 6 | - | type WebUIConfigAppearancePayload struct { | 9 | + | // the settings page across engines. |
| 7 | - | SansFont string `json:"sans_font"` | 10 | + | func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) dto.SystemviewConfigPayload { |
| 8 | - | SerifFont string `json:"serif_font"` | 11 | + | payload := dto.SystemviewConfigPayload{ |
| 9 | - | MonospaceFont string `json:"monospace_font"` | 12 | + | SearchShortcuts: map[string]dto.SearchShortcut{}, |
| 10 | - | DefaultFontSize int `json:"default_font_size"` | 13 | + | } |
| 11 | - | ColorScheme string `json:"color_scheme"` | 14 | + | if cfg == nil { |
| 12 | - | LightPalette ColorPalette `json:"light_palette"` | 15 | + | return payload |
| 13 | - | DarkPalette ColorPalette `json:"dark_palette"` | 16 | + | } |
| 14 | - | } | |||
| 15 | 17 | ||||
| 16 | - | // WebUIConfigHardwarePayload exposes detected hardware to the settings UI. | 18 | + | perfCfg := PerformanceConfigFromEngine(&cfg.Engine) |
| 17 | - | type WebUIConfigHardwarePayload struct { | 19 | + | resolved := ResolvePerformanceProfile(&perfCfg, hw) |
| 18 | - | CPUCores int `json:"cpu_cores"` | |||
| 19 | - | CPUThreads int `json:"cpu_threads"` | |||
| 20 | - | TotalRAMMB int `json:"total_ram_mb"` | |||
| 21 | - | GPUVendor string `json:"gpu_vendor"` | |||
| 22 | - | GPUName string `json:"gpu_name"` | |||
| 23 | - | VRAMMB int `json:"vram_mb"` | |||
| 24 | - | } | |||
| 25 | 20 | ||||
| 26 | - | // WebUIConfigCustomPerformancePayload holds user-editable custom profile values. | 21 | + | payload.Appearance = buildSystemviewAppearancePayload(cfg.Appearance) |
| 27 | - | type WebUIConfigCustomPerformancePayload struct { | 22 | + | payload.DefaultUIScale = cfg.DefaultUIScale |
| 28 | - | SkiaCPUThreads int `json:"skia_cpu_threads"` | 23 | + | payload.DefaultSearchEngine = cfg.DefaultSearchEngine |
| 29 | - | SkiaGPUThreads int `json:"skia_gpu_threads"` | 24 | + | payload.SearchShortcuts = buildSystemviewSearchShortcutsPayload(cfg.SearchShortcuts) |
| 30 | - | WebProcessMemoryMB int `json:"web_process_memory_mb"` | 25 | + | payload.EngineType = cfg.Engine.ResolveEngineType() |
| 31 | - | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | 26 | + | payload.Performance = dto.SystemviewPerformancePayload{ |
| 32 | - | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | 27 | + | Profile: string(cfg.Engine.Profile), |
| 33 | - | } | 28 | + | Custom: dto.SystemviewCustomPerformancePayload{ |
| 29 | + | SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads, | |||
| 30 | + | SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads, | |||
| 31 | + | WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB, | |||
| 32 | + | NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB, | |||
| 33 | + | WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount, | |||
| 34 | + | }, | |||
| 35 | + | Resolved: dto.SystemviewResolvedPerformancePayload{ | |||
| 36 | + | SkiaCPUThreads: resolved.SkiaCPUPaintingThreads, | |||
| 37 | + | SkiaGPUThreads: resolved.SkiaGPUPaintingThreads, | |||
| 38 | + | WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB, | |||
| 39 | + | NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB, | |||
| 40 | + | WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount, | |||
| 41 | + | ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold, | |||
| 42 | + | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | |||
| 43 | + | }, | |||
| 44 | + | Hardware: buildSystemviewHardwarePayload(hw), | |||
| 45 | + | } | |||
| 34 | 46 | ||||
| 35 | - | // WebUIConfigResolvedPerformancePayload holds the resolved effective values. | 47 | + | return payload |
| 36 | - | type WebUIConfigResolvedPerformancePayload struct { | |||
| 37 | - | SkiaCPUThreads int `json:"skia_cpu_threads"` | |||
| 38 | - | SkiaGPUThreads int `json:"skia_gpu_threads"` | |||
| 39 | - | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |||
| 40 | - | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |||
| 41 | - | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |||
| 42 | - | ConservativeThreshold float64 `json:"conservative_threshold"` | |||
| 43 | - | StrictThreshold float64 `json:"strict_threshold"` | |||
| 44 | } | 48 | } | ||
| 45 | 49 | ||||
| 46 | - | // WebUIConfigPerformancePayload is the performance tab payload expected by the UI. | 50 | + | func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppearanceConfig { |
| 47 | - | type WebUIConfigPerformancePayload struct { | 51 | + | defaults := DefaultConfig().Appearance |
| 48 | - | Profile string `json:"profile"` | 52 | + | return dto.WebUIAppearanceConfig{ |
| 49 | - | Custom WebUIConfigCustomPerformancePayload `json:"custom"` | 53 | + | SansFont: appearance.SansFont, |
| 50 | - | Resolved WebUIConfigResolvedPerformancePayload `json:"resolved"` | 54 | + | SerifFont: appearance.SerifFont, |
| 51 | - | Hardware WebUIConfigHardwarePayload `json:"hardware"` | 55 | + | MonospaceFont: appearance.MonospaceFont, |
| 56 | + | DefaultFontSize: appearance.DefaultFontSize, | |||
| 57 | + | ColorScheme: appearance.ColorScheme, | |||
| 58 | + | LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette), | |||
| 59 | + | DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette), | |||
| 60 | + | } | |||
| 52 | } | 61 | } | ||
| 53 | 62 | ||||
| 54 | - | // WebUIConfigPayload is the full JSON shape expected by dumb://config. | 63 | + | func buildSystemviewPalettePayload(palette, fallback ColorPalette) dto.ColorPalette { |
| 55 | - | type WebUIConfigPayload struct { | 64 | + | return dto.ColorPalette{ |
| 56 | - | Appearance WebUIConfigAppearancePayload `json:"appearance"` | 65 | + | Background: nonEmptyColor(palette.Background, fallback.Background), |
| 57 | - | Performance WebUIConfigPerformancePayload `json:"performance"` | 66 | + | Surface: nonEmptyColor(palette.Surface, fallback.Surface), |
| 58 | - | DefaultUIScale float64 `json:"default_ui_scale"` | 67 | + | SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant), |
| 59 | - | DefaultSearchEngine string `json:"default_search_engine"` | 68 | + | Text: nonEmptyColor(palette.Text, fallback.Text), |
| 60 | - | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | 69 | + | Muted: nonEmptyColor(palette.Muted, fallback.Muted), |
| 61 | - | EngineType string `json:"engine_type"` | 70 | + | Accent: nonEmptyColor(palette.Accent, fallback.Accent), |
| 71 | + | Border: nonEmptyColor(palette.Border, fallback.Border), | |||
| 72 | + | } | |||
| 62 | } | 73 | } | ||
| 63 | 74 | ||||
| 64 | - | // BuildWebUIConfigPayload projects the full config into the stable DTO used by | 75 | + | func nonEmptyColor(value, fallback string) string { |
| 65 | - | // the settings page across engines. | 76 | + | if value != "" { |
| 66 | - | func BuildWebUIConfigPayload(cfg *Config, hw *port.HardwareInfo) WebUIConfigPayload { | 77 | + | return value |
| 67 | - | if cfg == nil { | |||
| 68 | - | return WebUIConfigPayload{} | |||
| 69 | } | 78 | } | ||
| 79 | + | return fallback | |||
| 80 | + | } | |||
| 70 | 81 | ||||
| 71 | - | perfCfg := PerformanceConfigFromEngine(&cfg.Engine) | 82 | + | func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]dto.SearchShortcut { |
| 72 | - | resolved := ResolvePerformanceProfile(&perfCfg, hw) | 83 | + | if len(shortcuts) == 0 { |
| 84 | + | return map[string]dto.SearchShortcut{} | |||
| 85 | + | } | |||
| 73 | 86 | ||||
| 74 | - | return WebUIConfigPayload{ | 87 | + | result := make(map[string]dto.SearchShortcut, len(shortcuts)) |
| 75 | - | DefaultUIScale: cfg.DefaultUIScale, | 88 | + | for key, shortcut := range shortcuts { |
| 76 | - | DefaultSearchEngine: cfg.DefaultSearchEngine, | 89 | + | result[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description} |
| 77 | - | SearchShortcuts: cfg.SearchShortcuts, | |||
| 78 | - | EngineType: cfg.Engine.ResolveEngineType(), | |||
| 79 | - | Appearance: WebUIConfigAppearancePayload{ | |||
| 80 | - | SansFont: cfg.Appearance.SansFont, | |||
| 81 | - | SerifFont: cfg.Appearance.SerifFont, | |||
| 82 | - | MonospaceFont: cfg.Appearance.MonospaceFont, | |||
| 83 | - | DefaultFontSize: cfg.Appearance.DefaultFontSize, | |||
| 84 | - | ColorScheme: cfg.Appearance.ColorScheme, | |||
| 85 | - | LightPalette: cfg.Appearance.LightPalette, | |||
| 86 | - | DarkPalette: cfg.Appearance.DarkPalette, | |||
| 87 | - | }, | |||
| 88 | - | Performance: WebUIConfigPerformancePayload{ | |||
| 89 | - | Profile: string(cfg.Engine.Profile), | |||
| 90 | - | Custom: WebUIConfigCustomPerformancePayload{ | |||
| 91 | - | SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads, | |||
| 92 | - | SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads, | |||
| 93 | - | WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB, | |||
| 94 | - | NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB, | |||
| 95 | - | WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount, | |||
| 96 | - | }, | |||
| 97 | - | Resolved: WebUIConfigResolvedPerformancePayload{ | |||
| 98 | - | SkiaCPUThreads: resolved.SkiaCPUPaintingThreads, | |||
| 99 | - | SkiaGPUThreads: resolved.SkiaGPUPaintingThreads, | |||
| 100 | - | WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB, | |||
| 101 | - | NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB, | |||
| 102 | - | WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount, | |||
| 103 | - | ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold, | |||
| 104 | - | StrictThreshold: resolved.WebProcessMemoryStrictThreshold, | |||
| 105 | - | }, | |||
| 106 | - | Hardware: buildWebUIHardwarePayload(hw), | |||
| 107 | - | }, | |||
| 108 | } | 90 | } | ||
| 91 | + | return result | |||
| 109 | } | 92 | } | ||
| 110 | 93 | ||||
| 111 | - | func buildWebUIHardwarePayload(hw *port.HardwareInfo) WebUIConfigHardwarePayload { | 94 | + | func buildSystemviewHardwarePayload(hw *port.HardwareInfo) dto.SystemviewHardwarePayload { |
| 112 | if hw == nil { | 95 | if hw == nil { | ||
| 113 | - | return WebUIConfigHardwarePayload{} | 96 | + | return dto.SystemviewHardwarePayload{} |
| 114 | } | 97 | } | ||
| 115 | - | return WebUIConfigHardwarePayload{ | 98 | + | return dto.SystemviewHardwarePayload{ |
| 116 | CPUCores: hw.CPUCores, | 99 | CPUCores: hw.CPUCores, | ||
| 117 | CPUThreads: hw.CPUThreads, | 100 | CPUThreads: hw.CPUThreads, | ||
| 118 | TotalRAMMB: hw.TotalRAMMB(), | 101 | TotalRAMMB: hw.TotalRAMMB(), | ||
A internal/infrastructure/config/webui_payload_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestBuildSystemviewConfigPayload_EmptySearchShortcutsUsesEmptyMap(t *testing.T) { | |||
| 10 | + | cfg := DefaultConfig() | |||
| 11 | + | cfg.SearchShortcuts = map[string]SearchShortcut{} | |||
| 12 | + | payload := BuildSystemviewConfigPayload(cfg, nil) | |||
| 13 | + | require.NotNil(t, payload.SearchShortcuts) | |||
| 14 | + | require.Empty(t, payload.SearchShortcuts) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestBuildSystemviewConfigPayload_FillsMissingPaletteValuesFromDefaults(t *testing.T) { | |||
| 18 | + | cfg := DefaultConfig() | |||
| 19 | + | cfg.Appearance.LightPalette.Background = "" | |||
| 20 | + | cfg.Appearance.DarkPalette.Accent = "" | |||
| 21 | + | ||||
| 22 | + | payload := BuildSystemviewConfigPayload(cfg, nil) | |||
| 23 | + | defaults := DefaultConfig() | |||
| 24 | + | ||||
| 25 | + | require.Equal(t, defaults.Appearance.LightPalette.Background, payload.Appearance.LightPalette.Background) | |||
| 26 | + | require.Equal(t, defaults.Appearance.DarkPalette.Accent, payload.Appearance.DarkPalette.Accent) | |||
| 27 | + | } | |||
M internal/infrastructure/handlers/config.go +11 -6
| @@ -5,18 +5,19 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // ConfigHandler handles configuration-related messages. | 13 | // ConfigHandler handles configuration-related messages. | ||
| 13 | type ConfigHandler struct { | 14 | type ConfigHandler struct { | ||
| 14 | - | saveConfig func(context.Context, port.WebUIConfig) error | 15 | + | saveConfig func(context.Context, dto.WebUIConfig) error |
| 15 | } | 16 | } | ||
| 16 | 17 | ||||
| 17 | // NewConfigHandler creates a new ConfigHandler. | 18 | // NewConfigHandler creates a new ConfigHandler. | ||
| 18 | // saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute). | 19 | // saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute). | ||
| 19 | - | func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) *ConfigHandler { | 20 | + | func NewConfigHandler(saveConfig func(context.Context, dto.WebUIConfig) error) *ConfigHandler { |
| 20 | if saveConfig == nil { | 21 | if saveConfig == nil { | ||
| 21 | panic("NewConfigHandler: saveConfig must not be nil") | 22 | panic("NewConfigHandler: saveConfig must not be nil") | ||
| 22 | } | 23 | } | ||
| @@ -24,7 +25,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) | |||||
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 26 | // Handle processes the save_config message. | 27 | // Handle processes the save_config message. | ||
| 27 | - | func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 28 | + | func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, payload json.RawMessage) (any, error) { |
| 28 | if h == nil { | 29 | if h == nil { | ||
| 29 | return nil, fmt.Errorf("config handler is nil") | 30 | return nil, fmt.Errorf("config handler is nil") | ||
| 30 | } | 31 | } | ||
| @@ -33,13 +34,17 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js | |||||
| 33 | } | 34 | } | ||
| 34 | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | 35 | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | ||
| 35 | 36 | ||||
| 36 | - | var payloadCfg port.WebUIConfig | 37 | + | var payloadCfg dto.WebUIConfig |
| 37 | if err := json.Unmarshal(payload, &payloadCfg); err != nil { | 38 | if err := json.Unmarshal(payload, &payloadCfg); err != nil { | ||
| 38 | log.Error().Err(err).Msg("failed to unmarshal config payload") | 39 | log.Error().Err(err).Msg("failed to unmarshal config payload") | ||
| 39 | return nil, fmt.Errorf("invalid config format: %w", err) | 40 | return nil, fmt.Errorf("invalid config format: %w", err) | ||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | - | log.Info().Msg("saving appearance configuration from webui") | 43 | + | source := "webui" |
| 44 | + | if viewID != 0 { | |||
| 45 | + | source = fmt.Sprintf("webview:%d", viewID) | |||
| 46 | + | } | |||
| 47 | + | log.Info().Str("source", source).Msg("saving appearance configuration") | |||
| 43 | 48 | ||||
| 44 | if err := h.saveConfig(ctx, payloadCfg); err != nil { | 49 | if err := h.saveConfig(ctx, payloadCfg); err != nil { | ||
| 45 | log.Error().Err(err).Msg("failed to save config") | 50 | log.Error().Err(err).Msg("failed to save config") | ||
| @@ -52,7 +57,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js | |||||
| 52 | // RegisterConfigHandlers registers configuration handlers with the router. | 57 | // RegisterConfigHandlers registers configuration handlers with the router. | ||
| 53 | func RegisterConfigHandlers( | 58 | func RegisterConfigHandlers( | ||
| 54 | ctx context.Context, router port.WebUIHandlerRouter, | 59 | ctx context.Context, router port.WebUIHandlerRouter, | ||
| 55 | - | saveConfig func(context.Context, port.WebUIConfig) error, | 60 | + | saveConfig func(context.Context, dto.WebUIConfig) error, |
| 56 | ) error { | 61 | ) error { | ||
| 57 | handler := NewConfigHandler(saveConfig) | 62 | handler := NewConfigHandler(saveConfig) | ||
| 58 | 63 | ||||
M internal/infrastructure/handlers/homepage/favorites.go +168 -4
| @@ -3,12 +3,37 @@ package homepage | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| 15 | + | func folderIDFromInt64(id *int64) (*entity.FolderID, error) { | |||
| 16 | + | if id == nil { | |||
| 17 | + | return nil, nil | |||
| 18 | + | } | |||
| 19 | + | if *id <= 0 { | |||
| 20 | + | return nil, fmt.Errorf("folder id must be positive") | |||
| 21 | + | } | |||
| 22 | + | folderID := entity.FolderID(*id) | |||
| 23 | + | return &folderID, nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) { | |||
| 27 | + | out := make([]entity.TagID, 0, len(ids)) | |||
| 28 | + | for _, id := range ids { | |||
| 29 | + | if id <= 0 { | |||
| 30 | + | return nil, fmt.Errorf("tag id must be positive") | |||
| 31 | + | } | |||
| 32 | + | out = append(out, entity.TagID(id)) | |||
| 33 | + | } | |||
| 34 | + | return out, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 12 | // FavoritesHandlers handles favorite-related messages from the homepage. | 37 | // FavoritesHandlers handles favorite-related messages from the homepage. | ||
| 13 | type FavoritesHandlers struct { | 38 | type FavoritesHandlers struct { | ||
| 14 | favoritesUC port.HomepageFavorites | 39 | favoritesUC port.HomepageFavorites | ||
| @@ -39,6 +64,138 @@ func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler { | |||||
| 39 | }) | 64 | }) | ||
| 40 | } | 65 | } | ||
| 41 | 66 | ||||
| 67 | + | type favoriteCreateRequest struct { | |||
| 68 | + | RequestID string `json:"requestId"` | |||
| 69 | + | URL string `json:"url"` | |||
| 70 | + | Title string `json:"title"` | |||
| 71 | + | FaviconURL string `json:"favicon_url"` | |||
| 72 | + | FolderID *int64 `json:"folder_id"` | |||
| 73 | + | Tags []int64 `json:"tags"` | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // HandleCreate handles favorite_create messages. | |||
| 77 | + | func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler { | |||
| 78 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 79 | + | log := logging.FromContext(ctx) | |||
| 80 | + | ||||
| 81 | + | var req favoriteCreateRequest | |||
| 82 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 83 | + | return NewErrorResponse("", err), nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | log.Debug(). | |||
| 87 | + | Str("request_id", req.RequestID). | |||
| 88 | + | Msg("handling favorite_create") | |||
| 89 | + | ||||
| 90 | + | trimmedURL := strings.TrimSpace(req.URL) | |||
| 91 | + | if trimmedURL == "" { | |||
| 92 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil | |||
| 93 | + | } | |||
| 94 | + | folderID, err := folderIDFromInt64(req.FolderID) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 97 | + | } | |||
| 98 | + | tags, err := tagIDsFromInt64s(req.Tags) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | input := dto.FavoriteCreateInput{ | |||
| 104 | + | URL: trimmedURL, | |||
| 105 | + | Title: req.Title, | |||
| 106 | + | FaviconURL: req.FaviconURL, | |||
| 107 | + | FolderID: folderID, | |||
| 108 | + | Tags: tags, | |||
| 109 | + | } | |||
| 110 | + | favorite, err := h.favoritesUC.AddFavorite(ctx, input) | |||
| 111 | + | if err != nil { | |||
| 112 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | return NewSuccessResponse(req.RequestID, favorite), nil | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | type favoriteUpdateRequest struct { | |||
| 120 | + | RequestID string `json:"requestId"` | |||
| 121 | + | ID int64 `json:"id"` | |||
| 122 | + | Title string `json:"title"` | |||
| 123 | + | FaviconURL string `json:"favicon_url"` | |||
| 124 | + | FolderID *int64 `json:"folder_id"` | |||
| 125 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // HandleUpdate handles favorite_update messages. | |||
| 129 | + | func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler { | |||
| 130 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 131 | + | log := logging.FromContext(ctx) | |||
| 132 | + | ||||
| 133 | + | var req favoriteUpdateRequest | |||
| 134 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 135 | + | return NewErrorResponse("", err), nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | log.Debug(). | |||
| 139 | + | Str("request_id", req.RequestID). | |||
| 140 | + | Int64("favorite_id", req.ID). | |||
| 141 | + | Msg("handling favorite_update") | |||
| 142 | + | ||||
| 143 | + | if req.ID <= 0 { | |||
| 144 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | folderID, err := folderIDFromInt64(req.FolderID) | |||
| 148 | + | if err != nil { | |||
| 149 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | |||
| 153 | + | ID: entity.FavoriteID(req.ID), | |||
| 154 | + | Title: req.Title, | |||
| 155 | + | FaviconURL: req.FaviconURL, | |||
| 156 | + | FolderID: folderID, | |||
| 157 | + | ShortcutKey: req.ShortcutKey, | |||
| 158 | + | }) | |||
| 159 | + | if err != nil { | |||
| 160 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | return NewSuccessResponse(req.RequestID, favorite), nil | |||
| 164 | + | }) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | type favoriteDeleteRequest struct { | |||
| 168 | + | RequestID string `json:"requestId"` | |||
| 169 | + | ID int64 `json:"id"` | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // HandleDelete handles favorite_delete messages. | |||
| 173 | + | func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler { | |||
| 174 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 175 | + | log := logging.FromContext(ctx) | |||
| 176 | + | ||||
| 177 | + | var req favoriteDeleteRequest | |||
| 178 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 179 | + | return NewErrorResponse("", err), nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | log.Debug(). | |||
| 183 | + | Str("request_id", req.RequestID). | |||
| 184 | + | Int64("favorite_id", req.ID). | |||
| 185 | + | Msg("handling favorite_delete") | |||
| 186 | + | ||||
| 187 | + | if req.ID <= 0 { | |||
| 188 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil { | |||
| 192 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | return NewSuccessResponse(req.RequestID, nil), nil | |||
| 196 | + | }) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 42 | // setShortcutRequest is the payload for favorite_set_shortcut messages. | 199 | // setShortcutRequest is the payload for favorite_set_shortcut messages. | ||
| 43 | type setShortcutRequest struct { | 200 | type setShortcutRequest struct { | ||
| 44 | RequestID string `json:"requestId"` | 201 | RequestID string `json:"requestId"` | ||
| @@ -61,6 +218,10 @@ func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler { | |||||
| 61 | Int64("favorite_id", req.FavoriteID). | 218 | Int64("favorite_id", req.FavoriteID). | ||
| 62 | Msg("handling favorite_set_shortcut") | 219 | Msg("handling favorite_set_shortcut") | ||
| 63 | 220 | ||||
| 221 | + | if req.FavoriteID <= 0 { | |||
| 222 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite id")), nil | |||
| 223 | + | } | |||
| 224 | + | ||||
| 64 | if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil { | 225 | if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil { | ||
| 65 | return NewErrorResponse(req.RequestID, err), nil | 226 | return NewErrorResponse(req.RequestID, err), nil | ||
| 66 | } | 227 | } | ||
| @@ -121,10 +282,13 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler { | |||||
| 121 | Int64("favorite_id", req.FavoriteID). | 282 | Int64("favorite_id", req.FavoriteID). | ||
| 122 | Msg("handling favorite_set_folder") | 283 | Msg("handling favorite_set_folder") | ||
| 123 | 284 | ||||
| 124 | - | var folderID *entity.FolderID | 285 | + | if req.FavoriteID <= 0 { |
| 125 | - | if req.FolderID != nil { | 286 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil |
| 126 | - | id := entity.FolderID(*req.FolderID) | 287 | + | } |
| 127 | - | folderID = &id | 288 | + | |
| 289 | + | folderID, err := folderIDFromInt64(req.FolderID) | |||
| 290 | + | if err != nil { | |||
| 291 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 128 | } | 292 | } | ||
| 129 | 293 | ||||
| 130 | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil { | 294 | if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil { | ||
M internal/infrastructure/handlers/homepage/folders.go +13 -13
| @@ -44,6 +44,7 @@ type createFolderRequest struct { | |||||
| 44 | RequestID string `json:"requestId"` | 44 | RequestID string `json:"requestId"` | ||
| 45 | Name string `json:"name"` | 45 | Name string `json:"name"` | ||
| 46 | Icon *string `json:"icon"` | 46 | Icon *string `json:"icon"` | ||
| 47 | + | ParentID *int64 `json:"parent_id"` | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | // HandleCreate handles folder_create messages. | 50 | // HandleCreate handles folder_create messages. | ||
| @@ -61,17 +62,14 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler { | |||||
| 61 | Str("name", req.Name). | 62 | Str("name", req.Name). | ||
| 62 | Msg("handling folder_create") | 63 | Msg("handling folder_create") | ||
| 63 | 64 | ||||
| 64 | - | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, nil) | 65 | + | parentID, err := folderIDFromInt64(req.ParentID) |
| 65 | if err != nil { | 66 | if err != nil { | ||
| 66 | return NewErrorResponse(req.RequestID, err), nil | 67 | return NewErrorResponse(req.RequestID, err), nil | ||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | - | // Set icon if provided and persist it | 70 | + | folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID) |
| 70 | - | if req.Icon != nil && *req.Icon != "" { | 71 | + | if err != nil { |
| 71 | - | folder.Icon = *req.Icon | 72 | + | return NewErrorResponse(req.RequestID, err), nil |
| 72 | - | if err := h.favoritesUC.UpdateFolder(ctx, folder.ID, folder.Name, folder.Icon); err != nil { | |||
| 73 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 74 | - | } | |||
| 75 | } | 73 | } | ||
| 76 | 74 | ||||
| 77 | return NewSuccessResponse(req.RequestID, folder), nil | 75 | return NewSuccessResponse(req.RequestID, folder), nil | ||
| @@ -117,6 +115,13 @@ type updateFolderRequest struct { | |||||
| 117 | 115 | ||||
| 118 | // HandleUpdate handles folder_update messages. | 116 | // HandleUpdate handles folder_update messages. | ||
| 119 | // It delegates to the port.HomepageFavorites interface's UpdateFolder method. | 117 | // It delegates to the port.HomepageFavorites interface's UpdateFolder method. | ||
| 118 | + | func optionalStringValue(value *string) string { | |||
| 119 | + | if value == nil { | |||
| 120 | + | return "" | |||
| 121 | + | } | |||
| 122 | + | return *value | |||
| 123 | + | } | |||
| 124 | + | ||||
| 120 | func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler { | 125 | func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler { | ||
| 121 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 126 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
| 122 | log := logging.FromContext(ctx) | 127 | log := logging.FromContext(ctx) | ||
| @@ -132,12 +137,7 @@ func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler { | |||||
| 132 | Str("name", req.Name). | 137 | Str("name", req.Name). | ||
| 133 | Msg("handling folder_update") | 138 | Msg("handling folder_update") | ||
| 134 | 139 | ||||
| 135 | - | icon := "" | 140 | + | if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, optionalStringValue(req.Icon)); err != nil { |
| 136 | - | if req.Icon != nil { | |||
| 137 | - | icon = *req.Icon | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, icon); err != nil { | |||
| 141 | return NewErrorResponse(req.RequestID, err), nil | 141 | return NewErrorResponse(req.RequestID, err), nil | ||
| 142 | } | 142 | } | ||
| 143 | 143 | ||||
M internal/infrastructure/handlers/homepage/history.go +120 -29
| @@ -3,8 +3,11 @@ package homepage | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 6 | "time" | 8 | "time" | ||
| 7 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 12 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 10 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -27,6 +30,19 @@ type timelineRequest struct { | |||||
| 27 | Offset int `json:"offset"` | 30 | Offset int `json:"offset"` | ||
| 28 | } | 31 | } | ||
| 29 | 32 | ||||
| 33 | + | type timelineByDomainRequest struct { | |||
| 34 | + | RequestID string `json:"requestId"` | |||
| 35 | + | Domain string `json:"domain"` | |||
| 36 | + | Limit int `json:"limit"` | |||
| 37 | + | Offset int `json:"offset"` | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type timelineWindowRequest struct { | |||
| 41 | + | RequestID string `json:"requestId"` | |||
| 42 | + | Before string `json:"before"` | |||
| 43 | + | Domain string `json:"domain"` | |||
| 44 | + | } | |||
| 45 | + | ||||
| 30 | // HandleTimeline handles history_timeline messages. | 46 | // HandleTimeline handles history_timeline messages. | ||
| 31 | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | 47 | func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | ||
| 32 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 48 | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | ||
| @@ -42,6 +58,12 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |||||
| 42 | Int("limit", req.Limit). | 58 | Int("limit", req.Limit). | ||
| 43 | Int("offset", req.Offset). | 59 | Int("offset", req.Offset). | ||
| 44 | Msg("handling history_timeline") | 60 | Msg("handling history_timeline") | ||
| 61 | + | if req.Limit <= 0 { | |||
| 62 | + | return NewErrorResponse( | |||
| 63 | + | req.RequestID, | |||
| 64 | + | fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"), | |||
| 65 | + | ), nil | |||
| 66 | + | } | |||
| 45 | 67 | ||||
| 46 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | 68 | entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset) | ||
| 47 | if err != nil { | 69 | if err != nil { | ||
| @@ -52,6 +74,79 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler { | |||||
| 52 | }) | 74 | }) | ||
| 53 | } | 75 | } | ||
| 54 | 76 | ||||
| 77 | + | // HandleTimelineByDomain handles history_timeline_by_domain messages. | |||
| 78 | + | func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler { | |||
| 79 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 80 | + | log := logging.FromContext(ctx) | |||
| 81 | + | ||||
| 82 | + | var req timelineByDomainRequest | |||
| 83 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 84 | + | return NewErrorResponse("", err), nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | req.Domain = strings.TrimSpace(req.Domain) | |||
| 88 | + | if req.Domain == "" { | |||
| 89 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | log.Debug(). | |||
| 93 | + | Str("request_id", req.RequestID). | |||
| 94 | + | Str("domain", req.Domain). | |||
| 95 | + | Int("limit", req.Limit). | |||
| 96 | + | Int("offset", req.Offset). | |||
| 97 | + | Msg("handling history_timeline_by_domain") | |||
| 98 | + | if req.Limit <= 0 { | |||
| 99 | + | return NewErrorResponse( | |||
| 100 | + | req.RequestID, | |||
| 101 | + | fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"), | |||
| 102 | + | ), nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset) | |||
| 106 | + | if err != nil { | |||
| 107 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | return NewSuccessResponse(req.RequestID, entries), nil | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // HandleTimelineWindow handles history_timeline_window messages. | |||
| 115 | + | func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler { | |||
| 116 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |||
| 117 | + | log := logging.FromContext(ctx) | |||
| 118 | + | ||||
| 119 | + | var req timelineWindowRequest | |||
| 120 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 121 | + | return NewErrorResponse("", err), nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | domain := strings.TrimSpace(req.Domain) | |||
| 125 | + | ||||
| 126 | + | var before time.Time | |||
| 127 | + | if strings.TrimSpace(req.Before) != "" { | |||
| 128 | + | parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before)) | |||
| 129 | + | if err != nil { | |||
| 130 | + | return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil | |||
| 131 | + | } | |||
| 132 | + | before = parsed | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | log.Debug(). | |||
| 136 | + | Str("request_id", req.RequestID). | |||
| 137 | + | Str("domain", domain). | |||
| 138 | + | Time("before", before). | |||
| 139 | + | Msg("handling history_timeline_window") | |||
| 140 | + | ||||
| 141 | + | window, err := h.historyUC.GetRecentWindow(ctx, before, domain) | |||
| 142 | + | if err != nil { | |||
| 143 | + | return NewErrorResponse(req.RequestID, err), nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | return NewSuccessResponse(req.RequestID, window), nil | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | ||||
| 55 | // searchRequest is the payload for history_search_fts messages. | 150 | // searchRequest is the payload for history_search_fts messages. | ||
| 56 | type searchRequest struct { | 151 | type searchRequest struct { | ||
| 57 | RequestID string `json:"requestId"` | 152 | RequestID string `json:"requestId"` | ||
| @@ -74,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler { | |||||
| 74 | Str("query", req.Query). | 169 | Str("query", req.Query). | ||
| 75 | Msg("handling history_search_fts") | 170 | Msg("handling history_search_fts") | ||
| 76 | 171 | ||||
| 77 | - | output, err := h.historyUC.Search(ctx, port.HistorySearchInput{ | 172 | + | output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{ |
| 78 | Query: req.Query, | 173 | Query: req.Query, | ||
| 79 | Limit: req.Limit, | 174 | Limit: req.Limit, | ||
| 80 | }) | 175 | }) | ||
| @@ -137,21 +232,14 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler { | |||||
| 137 | return NewErrorResponse("", err), nil | 232 | return NewErrorResponse("", err), nil | ||
| 138 | } | 233 | } | ||
| 139 | 234 | ||||
| 235 | + | req.Range = strings.TrimSpace(req.Range) | |||
| 140 | log.Debug(). | 236 | log.Debug(). | ||
| 141 | Str("request_id", req.RequestID). | 237 | Str("request_id", req.RequestID). | ||
| 142 | Str("range", req.Range). | 238 | Str("range", req.Range). | ||
| 143 | Msg("handling history_delete_range") | 239 | Msg("handling history_delete_range") | ||
| 144 | 240 | ||||
| 145 | - | before := rangeToTime(req.Range) | 241 | + | if err := h.historyUC.ClearRange(ctx, req.Range); err != nil { |
| 146 | - | if before.IsZero() { | 242 | + | return NewErrorResponse(req.RequestID, err), nil |
| 147 | - | // "all" case - clear all | |||
| 148 | - | if err := h.historyUC.ClearAll(ctx); err != nil { | |||
| 149 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 150 | - | } | |||
| 151 | - | } else { | |||
| 152 | - | if err := h.historyUC.ClearOlderThan(ctx, before); err != nil { | |||
| 153 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 154 | - | } | |||
| 155 | } | 243 | } | ||
| 156 | 244 | ||||
| 157 | return NewSuccessResponse(req.RequestID, nil), nil | 245 | return NewSuccessResponse(req.RequestID, nil), nil | ||
| @@ -177,24 +265,27 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler { | |||||
| 177 | }) | 265 | }) | ||
| 178 | } | 266 | } | ||
| 179 | 267 | ||||
| 180 | - | // rangeToTime converts a range string to a time.Time cutoff. | 268 | + | // HandleStats handles history_stats messages. |
| 181 | - | // Returns zero time for "all" which means delete everything. | 269 | + | func (h *HistoryHandlers) HandleStats() port.WebUIMessageHandler { |
| 182 | - | func rangeToTime(r string) time.Time { | 270 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { |
| 183 | - | now := time.Now() | 271 | + | log := logging.FromContext(ctx) |
| 184 | - | switch r { | 272 | + | |
| 185 | - | case "hour": | 273 | + | var req struct { |
| 186 | - | return now.Add(-time.Hour) | 274 | + | RequestID string `json:"requestId"` |
| 187 | - | case "day": | 275 | + | } |
| 188 | - | return now.AddDate(0, 0, -1) | 276 | + | if err := json.Unmarshal(payload, &req); err != nil { |
| 189 | - | case "week": | 277 | + | return NewErrorResponse("", err), nil |
| 190 | - | return now.AddDate(0, 0, -7) | 278 | + | } |
| 191 | - | case "month": | 279 | + | |
| 192 | - | return now.AddDate(0, -1, 0) | 280 | + | log.Debug().Str("request_id", req.RequestID).Msg("handling history_stats") |
| 193 | - | case "all": | 281 | + | |
| 194 | - | return time.Time{} // Zero time signals "delete all" | 282 | + | stats, err := h.historyUC.GetStats(ctx) |
| 195 | - | default: | 283 | + | if err != nil { |
| 196 | - | return now | 284 | + | return NewErrorResponse(req.RequestID, err), nil |
| 197 | - | } | 285 | + | } |
| 286 | + | ||||
| 287 | + | return NewSuccessResponse(req.RequestID, stats), nil | |||
| 288 | + | }) | |||
| 198 | } | 289 | } | ||
| 199 | 290 | ||||
| 200 | // HandleAnalytics handles history_analytics messages. | 291 | // HandleAnalytics handles history_analytics messages. | ||
M internal/infrastructure/handlers/homepage/registry.go +6 -0
| @@ -29,10 +29,13 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 29 | // History handlers | 29 | // History handlers | ||
| 30 | historyHandlers := NewHistoryHandlers(cfg.HistoryUC) | 30 | historyHandlers := NewHistoryHandlers(cfg.HistoryUC) | ||
| 31 | handlers["history_timeline"] = historyHandlers.HandleTimeline() | 31 | handlers["history_timeline"] = historyHandlers.HandleTimeline() | ||
| 32 | + | handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain() | |||
| 33 | + | handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow() | |||
| 32 | handlers["history_search_fts"] = historyHandlers.HandleSearchFTS() | 34 | handlers["history_search_fts"] = historyHandlers.HandleSearchFTS() | ||
| 33 | handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry() | 35 | handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry() | ||
| 34 | handlers["history_delete_range"] = historyHandlers.HandleDeleteRange() | 36 | handlers["history_delete_range"] = historyHandlers.HandleDeleteRange() | ||
| 35 | handlers["history_clear_all"] = historyHandlers.HandleClearAll() | 37 | handlers["history_clear_all"] = historyHandlers.HandleClearAll() | ||
| 38 | + | handlers["history_stats"] = historyHandlers.HandleStats() | |||
| 36 | handlers["history_analytics"] = historyHandlers.HandleAnalytics() | 39 | handlers["history_analytics"] = historyHandlers.HandleAnalytics() | ||
| 37 | handlers["history_domain_stats"] = historyHandlers.HandleDomainStats() | 40 | handlers["history_domain_stats"] = historyHandlers.HandleDomainStats() | ||
| 38 | handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain() | 41 | handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain() | ||
| @@ -40,6 +43,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C | |||||
| 40 | // Favorites handlers | 43 | // Favorites handlers | ||
| 41 | favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC) | 44 | favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC) | ||
| 42 | handlers["favorite_list"] = favoritesHandlers.HandleList() | 45 | handlers["favorite_list"] = favoritesHandlers.HandleList() | ||
| 46 | + | handlers["favorite_create"] = favoritesHandlers.HandleCreate() | |||
| 47 | + | handlers["favorite_update"] = favoritesHandlers.HandleUpdate() | |||
| 48 | + | handlers["favorite_delete"] = favoritesHandlers.HandleDelete() | |||
| 43 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | 49 | handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut() | ||
| 44 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | 50 | handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut() | ||
| 45 | handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder() | 51 | handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder() | ||
A internal/infrastructure/netguard/transcode.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package netguard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/netip" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | var blockedTranscodeIPPfxs = []netip.Prefix{ | |||
| 9 | + | netip.MustParsePrefix("0.0.0.0/8"), | |||
| 10 | + | netip.MustParsePrefix("10.0.0.0/8"), | |||
| 11 | + | netip.MustParsePrefix("100.64.0.0/10"), | |||
| 12 | + | netip.MustParsePrefix("127.0.0.0/8"), | |||
| 13 | + | netip.MustParsePrefix("169.254.0.0/16"), | |||
| 14 | + | netip.MustParsePrefix("172.16.0.0/12"), | |||
| 15 | + | netip.MustParsePrefix("192.0.0.0/24"), | |||
| 16 | + | netip.MustParsePrefix("192.0.2.0/24"), | |||
| 17 | + | netip.MustParsePrefix("192.168.0.0/16"), | |||
| 18 | + | netip.MustParsePrefix("198.18.0.0/15"), | |||
| 19 | + | netip.MustParsePrefix("198.51.100.0/24"), | |||
| 20 | + | netip.MustParsePrefix("203.0.113.0/24"), | |||
| 21 | + | netip.MustParsePrefix("224.0.0.0/4"), | |||
| 22 | + | netip.MustParsePrefix("240.0.0.0/4"), | |||
| 23 | + | netip.MustParsePrefix("::/128"), | |||
| 24 | + | netip.MustParsePrefix("::1/128"), | |||
| 25 | + | netip.MustParsePrefix("64:ff9b::/96"), | |||
| 26 | + | netip.MustParsePrefix("64:ff9b:1::/48"), | |||
| 27 | + | netip.MustParsePrefix("100::/64"), | |||
| 28 | + | netip.MustParsePrefix("2001::/32"), | |||
| 29 | + | netip.MustParsePrefix("2001:2::/48"), | |||
| 30 | + | netip.MustParsePrefix("2001:db8::/32"), | |||
| 31 | + | netip.MustParsePrefix("2002::/16"), | |||
| 32 | + | netip.MustParsePrefix("fc00::/7"), | |||
| 33 | + | netip.MustParsePrefix("fe80::/10"), | |||
| 34 | + | netip.MustParsePrefix("ff00::/8"), | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // IsBlockedTranscodeIP reports whether ip belongs to a local, private, | |||
| 38 | + | // documentation, transition, or otherwise special-purpose range that a media | |||
| 39 | + | // transcoder must not fetch on behalf of web content. | |||
| 40 | + | func IsBlockedTranscodeIP(ip net.IP) bool { | |||
| 41 | + | addr, ok := netip.AddrFromSlice(ip) | |||
| 42 | + | if !ok { | |||
| 43 | + | return true | |||
| 44 | + | } | |||
| 45 | + | addr = addr.Unmap() | |||
| 46 | + | for _, prefix := range blockedTranscodeIPPfxs { | |||
| 47 | + | if prefix.Contains(addr) { | |||
| 48 | + | return true | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | return false | |||
| 52 | + | } | |||
A internal/infrastructure/netguard/transcode_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package netguard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) { | |||
| 9 | + | tests := []string{ | |||
| 10 | + | "127.0.0.1", | |||
| 11 | + | "10.0.0.1", | |||
| 12 | + | "172.16.0.1", | |||
| 13 | + | "192.168.1.1", | |||
| 14 | + | "100.64.0.1", | |||
| 15 | + | "198.18.0.1", | |||
| 16 | + | "192.0.2.1", | |||
| 17 | + | "169.254.169.254", | |||
| 18 | + | "0.0.0.0", | |||
| 19 | + | "64:ff9b::a00:1", | |||
| 20 | + | "2001::1", | |||
| 21 | + | "2001:db8::1", | |||
| 22 | + | "2002:0a00:0001::1", | |||
| 23 | + | "fc00::1", | |||
| 24 | + | "fe80::1", | |||
| 25 | + | "::ffff:127.0.0.1", | |||
| 26 | + | } | |||
| 27 | + | for _, rawIP := range tests { | |||
| 28 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 29 | + | if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 30 | + | t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) { | |||
| 37 | + | if !IsBlockedTranscodeIP(net.ParseIP("")) { | |||
| 38 | + | t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true") | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) { | |||
| 43 | + | for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} { | |||
| 44 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 45 | + | if IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 46 | + | t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP) | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
M internal/infrastructure/persistence/sqlite/favorite_repo.go +62 -4
| @@ -14,23 +14,28 @@ import ( | |||||
| 14 | // ==================== Favorite Repository ==================== | 14 | // ==================== Favorite Repository ==================== | ||
| 15 | 15 | ||||
| 16 | type favoriteRepo struct { | 16 | type favoriteRepo struct { | ||
| 17 | + | db *sql.DB | |||
| 17 | queries *sqlc.Queries | 18 | queries *sqlc.Queries | ||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | // NewFavoriteRepository creates a new SQLite-backed favorite repository. | 21 | // NewFavoriteRepository creates a new SQLite-backed favorite repository. | ||
| 21 | func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository { | 22 | func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository { | ||
| 22 | - | return &favoriteRepo{queries: sqlc.New(db)} | 23 | + | return &favoriteRepo{db: db, queries: sqlc.New(db)} |
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | 26 | func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | ||
| 26 | log := logging.FromContext(ctx) | 27 | log := logging.FromContext(ctx) | ||
| 27 | - | log.Debug().Str("url", fav.URL).Msg("saving favorite") | 28 | + | log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite") |
| 28 | 29 | ||||
| 29 | var folderID sql.NullInt64 | 30 | var folderID sql.NullInt64 | ||
| 30 | if fav.FolderID != nil { | 31 | if fav.FolderID != nil { | ||
| 31 | folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true} | 32 | folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true} | ||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 35 | + | if fav.ID > 0 { | |||
| 36 | + | return r.updateExisting(ctx, fav, folderID) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 34 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | 39 | row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{ | ||
| 35 | Url: fav.URL, | 40 | Url: fav.URL, | ||
| 36 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | 41 | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | ||
| @@ -44,6 +49,43 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | |||||
| 44 | return nil | 49 | return nil | ||
| 45 | } | 50 | } | ||
| 46 | 51 | ||||
| 52 | + | func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error { | |||
| 53 | + | log := logging.FromContext(ctx) | |||
| 54 | + | tx, err := r.db.BeginTx(ctx, nil) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | defer func() { | |||
| 59 | + | if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { | |||
| 60 | + | log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error") | |||
| 61 | + | } | |||
| 62 | + | }() | |||
| 63 | + | ||||
| 64 | + | shortcutKey := sql.NullInt64{} | |||
| 65 | + | if fav.ShortcutKey != nil { | |||
| 66 | + | shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | queries := r.queries.WithTx(tx) | |||
| 70 | + | if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{ | |||
| 71 | + | Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""}, | |||
| 72 | + | FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""}, | |||
| 73 | + | ID: int64(fav.ID), | |||
| 74 | + | }); err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil { | |||
| 78 | + | return err | |||
| 79 | + | } | |||
| 80 | + | if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{ | |||
| 81 | + | ShortcutKey: shortcutKey, | |||
| 82 | + | ID: int64(fav.ID), | |||
| 83 | + | }); err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | return tx.Commit() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 47 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | 89 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | ||
| 48 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | 90 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | ||
| 49 | if err != nil { | 91 | if err != nil { | ||
| @@ -175,13 +217,21 @@ func NewFolderRepository(db *sql.DB) repository.FolderRepository { | |||||
| 175 | 217 | ||||
| 176 | func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | 218 | func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | ||
| 177 | log := logging.FromContext(ctx) | 219 | log := logging.FromContext(ctx) | ||
| 178 | - | log.Debug().Str("name", folder.Name).Msg("saving folder") | 220 | + | log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder") |
| 179 | 221 | ||||
| 180 | var parentID sql.NullInt64 | 222 | var parentID sql.NullInt64 | ||
| 181 | if folder.ParentID != nil { | 223 | if folder.ParentID != nil { | ||
| 182 | parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true} | 224 | parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true} | ||
| 183 | } | 225 | } | ||
| 184 | 226 | ||||
| 227 | + | if folder.ID > 0 { | |||
| 228 | + | return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{ | |||
| 229 | + | Name: folder.Name, | |||
| 230 | + | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | |||
| 231 | + | ID: int64(folder.ID), | |||
| 232 | + | }) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 185 | row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{ | 235 | row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{ | ||
| 186 | Name: folder.Name, | 236 | Name: folder.Name, | ||
| 187 | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | 237 | Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""}, | ||
| @@ -275,7 +325,15 @@ func NewTagRepository(db *sql.DB) repository.TagRepository { | |||||
| 275 | 325 | ||||
| 276 | func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error { | 326 | func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error { | ||
| 277 | log := logging.FromContext(ctx) | 327 | log := logging.FromContext(ctx) | ||
| 278 | - | log.Debug().Str("name", tag.Name).Msg("saving tag") | 328 | + | log.Debug().Str("name", tag.Name).Int64("id", int64(tag.ID)).Msg("saving tag") |
| 329 | + | ||||
| 330 | + | if tag.ID > 0 { | |||
| 331 | + | return r.queries.UpdateTag(ctx, sqlc.UpdateTagParams{ | |||
| 332 | + | Name: tag.Name, | |||
| 333 | + | Color: tag.Color, | |||
| 334 | + | ID: int64(tag.ID), | |||
| 335 | + | }) | |||
| 336 | + | } | |||
| 279 | 337 | ||||
| 280 | row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{ | 338 | row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{ | ||
| 281 | Name: tag.Name, | 339 | Name: tag.Name, | ||
M internal/infrastructure/persistence/sqlite/history_repo.go +106 -30
| @@ -61,10 +61,12 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro | |||||
| 61 | log := logging.FromContext(ctx) | 61 | log := logging.FromContext(ctx) | ||
| 62 | log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry") | 62 | log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry") | ||
| 63 | 63 | ||||
| 64 | + | domain := domainurl.CanonicalDomain(entry.URL) | |||
| 64 | err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{ | 65 | err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{ | ||
| 65 | Url: entry.URL, | 66 | Url: entry.URL, | ||
| 66 | Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""}, | 67 | Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""}, | ||
| 67 | FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""}, | 68 | FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""}, | ||
| 69 | + | Domain: sql.NullString{String: domain, Valid: domain != ""}, | |||
| 68 | }) | 70 | }) | ||
| 69 | if err != nil { | 71 | if err != nil { | ||
| 70 | return err | 72 | return err | ||
| @@ -481,6 +483,13 @@ func isExpectedFTSError(err error) bool { | |||||
| 481 | } | 483 | } | ||
| 482 | 484 | ||||
| 483 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | 485 | func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | ||
| 486 | + | if limit <= 0 { | |||
| 487 | + | rows, err := r.queries.GetAllRecentHistory(ctx) | |||
| 488 | + | if err != nil { | |||
| 489 | + | return nil, err | |||
| 490 | + | } | |||
| 491 | + | return historyEntriesFromRows(rows), nil | |||
| 492 | + | } | |||
| 484 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | 493 | rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{ | ||
| 485 | Limit: int64(limit), | 494 | Limit: int64(limit), | ||
| 486 | Offset: int64(offset), | 495 | Offset: int64(offset), | ||
| @@ -488,12 +497,83 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti | |||||
| 488 | if err != nil { | 497 | if err != nil { | ||
| 489 | return nil, err | 498 | return nil, err | ||
| 490 | } | 499 | } | ||
| 500 | + | return historyEntriesFromRows(rows), nil | |||
| 501 | + | } | |||
| 491 | 502 | ||||
| 492 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 503 | + | func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { |
| 493 | - | for i := range rows { | 504 | + | rawDomain := domain |
| 494 | - | entries[i] = historyFromRow(rows[i]) | 505 | + | domain = domainurl.CanonicalDomain(domain) |
| 506 | + | if domain == "" { | |||
| 507 | + | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | |||
| 508 | + | } | |||
| 509 | + | if limit <= 0 { | |||
| 510 | + | rows, err := r.queries.GetAllRecentHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true}) | |||
| 511 | + | if err != nil { | |||
| 512 | + | return nil, err | |||
| 513 | + | } | |||
| 514 | + | return historyEntriesFromRows(rows), nil | |||
| 515 | + | } | |||
| 516 | + | rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{ | |||
| 517 | + | Domain: sql.NullString{String: domain, Valid: true}, | |||
| 518 | + | Limit: int64(limit), | |||
| 519 | + | Offset: int64(offset), | |||
| 520 | + | }) | |||
| 521 | + | if err != nil { | |||
| 522 | + | return nil, err | |||
| 523 | + | } | |||
| 524 | + | return historyEntriesFromRows(rows), nil | |||
| 525 | + | } | |||
| 526 | + | ||||
| 527 | + | func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 528 | + | rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{ | |||
| 529 | + | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 530 | + | After: sql.NullTime{Time: after, Valid: true}, | |||
| 531 | + | }) | |||
| 532 | + | if err != nil { | |||
| 533 | + | return nil, err | |||
| 534 | + | } | |||
| 535 | + | return historyEntriesFromRows(rows), nil | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 539 | + | rawDomain := domain | |||
| 540 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 541 | + | if domain == "" { | |||
| 542 | + | return nil, fmt.Errorf("history domain is required: %q", rawDomain) | |||
| 543 | + | } | |||
| 544 | + | rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{ | |||
| 545 | + | Domain: sql.NullString{String: domain, Valid: true}, | |||
| 546 | + | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 547 | + | After: sql.NullTime{Time: after, Valid: true}, | |||
| 548 | + | }) | |||
| 549 | + | if err != nil { | |||
| 550 | + | return nil, err | |||
| 551 | + | } | |||
| 552 | + | return historyEntriesFromRows(rows), nil | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 556 | + | value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true}) | |||
| 557 | + | if err != nil { | |||
| 558 | + | return false, err | |||
| 495 | } | 559 | } | ||
| 496 | - | return entries, nil | 560 | + | return value != 0, nil |
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 564 | + | rawDomain := domain | |||
| 565 | + | domain = domainurl.CanonicalDomain(domain) | |||
| 566 | + | if domain == "" { | |||
| 567 | + | return false, fmt.Errorf("history domain is required: %q", rawDomain) | |||
| 568 | + | } | |||
| 569 | + | value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{ | |||
| 570 | + | Domain: sql.NullString{String: domain, Valid: true}, | |||
| 571 | + | Before: sql.NullTime{Time: before, Valid: true}, | |||
| 572 | + | }) | |||
| 573 | + | if err != nil { | |||
| 574 | + | return false, err | |||
| 575 | + | } | |||
| 576 | + | return value != 0, nil | |||
| 497 | } | 577 | } | ||
| 498 | 578 | ||||
| 499 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 579 | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| @@ -507,11 +587,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H | |||||
| 507 | return nil, err | 587 | return nil, err | ||
| 508 | } | 588 | } | ||
| 509 | 589 | ||||
| 510 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 590 | + | return historyEntriesFromRows(rows), nil |
| 511 | - | for i := range rows { | |||
| 512 | - | entries[i] = historyFromRow(rows[i]) | |||
| 513 | - | } | |||
| 514 | - | return entries, nil | |||
| 515 | } | 591 | } | ||
| 516 | 592 | ||||
| 517 | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 593 | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| @@ -525,11 +601,7 @@ func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.H | |||||
| 525 | return nil, err | 601 | return nil, err | ||
| 526 | } | 602 | } | ||
| 527 | 603 | ||||
| 528 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 604 | + | return historyEntriesFromRows(rows), nil |
| 529 | - | for i := range rows { | |||
| 530 | - | entries[i] = historyFromRow(rows[i]) | |||
| 531 | - | } | |||
| 532 | - | return entries, nil | |||
| 533 | } | 605 | } | ||
| 534 | 606 | ||||
| 535 | func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | 607 | func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | ||
| @@ -538,11 +610,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor | |||||
| 538 | return nil, err | 610 | return nil, err | ||
| 539 | } | 611 | } | ||
| 540 | 612 | ||||
| 541 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 613 | + | return historyEntriesFromRows(rows), nil |
| 542 | - | for i := range rows { | |||
| 543 | - | entries[i] = historyFromRow(rows[i]) | |||
| 544 | - | } | |||
| 545 | - | return entries, nil | |||
| 546 | } | 614 | } | ||
| 547 | 615 | ||||
| 548 | func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) { | 616 | func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) { | ||
| @@ -551,11 +619,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE | |||||
| 551 | return nil, err | 619 | return nil, err | ||
| 552 | } | 620 | } | ||
| 553 | 621 | ||||
| 554 | - | entries := make([]*entity.HistoryEntry, len(rows)) | 622 | + | return historyEntriesFromRows(rows), nil |
| 555 | - | for i := range rows { | |||
| 556 | - | entries[i] = historyFromRow(rows[i]) | |||
| 557 | - | } | |||
| 558 | - | return entries, nil | |||
| 559 | } | 623 | } | ||
| 560 | 624 | ||||
| 561 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | 625 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error { | ||
| @@ -588,17 +652,21 @@ func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) err | |||||
| 588 | return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true}) | 652 | return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true}) | ||
| 589 | } | 653 | } | ||
| 590 | 654 | ||||
| 655 | + | func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error { | |||
| 656 | + | return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true}) | |||
| 657 | + | } | |||
| 658 | + | ||||
| 591 | func (r *historyRepo) DeleteAll(ctx context.Context) error { | 659 | func (r *historyRepo) DeleteAll(ctx context.Context) error { | ||
| 592 | return r.queries.DeleteAllHistory(ctx) | 660 | return r.queries.DeleteAllHistory(ctx) | ||
| 593 | } | 661 | } | ||
| 594 | 662 | ||||
| 595 | func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error { | 663 | func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error { | ||
| 596 | - | return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{ | 664 | + | rawDomain := domain |
| 597 | - | Column1: sql.NullString{String: domain, Valid: true}, | 665 | + | domain = domainurl.CanonicalDomain(domain) |
| 598 | - | Column2: sql.NullString{String: domain, Valid: true}, | 666 | + | if domain == "" { |
| 599 | - | Column3: sql.NullString{String: domain, Valid: true}, | 667 | + | return fmt.Errorf("history domain is required: %q", rawDomain) |
| 600 | - | Column4: sql.NullString{String: domain, Valid: true}, | 668 | + | } |
| 601 | - | }) | 669 | + | return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true}) |
| 602 | } | 670 | } | ||
| 603 | 671 | ||||
| 604 | func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | 672 | func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | ||
| @@ -694,6 +762,14 @@ func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([ | |||||
| 694 | return counts, nil | 762 | return counts, nil | ||
| 695 | } | 763 | } | ||
| 696 | 764 | ||||
| 765 | + | func historyEntriesFromRows(rows []sqlc.History) []*entity.HistoryEntry { | |||
| 766 | + | entries := make([]*entity.HistoryEntry, len(rows)) | |||
| 767 | + | for i := range rows { | |||
| 768 | + | entries[i] = historyFromRow(rows[i]) | |||
| 769 | + | } | |||
| 770 | + | return entries | |||
| 771 | + | } | |||
| 772 | + | ||||
| 697 | func historyFromRow(row sqlc.History) *entity.HistoryEntry { | 773 | func historyFromRow(row sqlc.History) *entity.HistoryEntry { | ||
| 698 | return &entity.HistoryEntry{ | 774 | return &entity.HistoryEntry{ | ||
| 699 | ID: row.ID, | 775 | ID: row.ID, | ||
M internal/infrastructure/persistence/sqlite/history_repo_test.go +58 -0
| @@ -191,6 +191,10 @@ func TestHistoryRepository_CRUD(t *testing.T) { | |||||
| 191 | require.NoError(t, err) | 191 | require.NoError(t, err) | ||
| 192 | require.Len(t, recent, 1) | 192 | require.Len(t, recent, 1) | ||
| 193 | 193 | ||||
| 194 | + | allRecent, err := repo.GetRecent(ctx, 0, 0) | |||
| 195 | + | require.NoError(t, err) | |||
| 196 | + | require.Len(t, allRecent, 1) | |||
| 197 | + | ||||
| 194 | // Delete | 198 | // Delete | ||
| 195 | require.NoError(t, repo.Delete(ctx, found.ID)) | 199 | require.NoError(t, repo.Delete(ctx, found.ID)) | ||
| 196 | 200 | ||||
| @@ -832,3 +836,57 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) | |||||
| 832 | require.NoError(t, err) | 836 | require.NoError(t, err) | ||
| 833 | assert.Empty(t, results) | 837 | assert.Empty(t, results) | ||
| 834 | } | 838 | } | ||
| 839 | + | ||||
| 840 | + | func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) { | |||
| 841 | + | ctx := historyTestCtx() | |||
| 842 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 843 | + | ||||
| 844 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 845 | + | require.NoError(t, err) | |||
| 846 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 847 | + | ||||
| 848 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 849 | + | ||||
| 850 | + | entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0) | |||
| 851 | + | require.Error(t, err) | |||
| 852 | + | assert.Nil(t, entries) | |||
| 853 | + | assert.Error(t, repo.DeleteByDomain(ctx, "/path")) | |||
| 854 | + | } | |||
| 855 | + | ||||
| 856 | + | func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) { | |||
| 857 | + | ctx := historyTestCtx() | |||
| 858 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 859 | + | ||||
| 860 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 861 | + | require.NoError(t, err) | |||
| 862 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 863 | + | ||||
| 864 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 865 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"})) | |||
| 866 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"})) | |||
| 867 | + | require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"})) | |||
| 868 | + | ||||
| 869 | + | exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0) | |||
| 870 | + | require.NoError(t, err) | |||
| 871 | + | require.Len(t, exampleEntries, 2) | |||
| 872 | + | for _, entry := range exampleEntries { | |||
| 873 | + | assert.Contains(t, entry.URL, "example.com") | |||
| 874 | + | } | |||
| 875 | + | ||||
| 876 | + | stats, err := repo.GetDomainStats(ctx, 10) | |||
| 877 | + | require.NoError(t, err) | |||
| 878 | + | require.NotEmpty(t, stats) | |||
| 879 | + | assert.Equal(t, "example.com", stats[0].Domain) | |||
| 880 | + | assert.Equal(t, int64(2), stats[0].PageCount) | |||
| 881 | + | ||||
| 882 | + | require.NoError(t, repo.DeleteByDomain(ctx, "example.com")) | |||
| 883 | + | ||||
| 884 | + | exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0) | |||
| 885 | + | require.NoError(t, err) | |||
| 886 | + | assert.Empty(t, exampleEntries) | |||
| 887 | + | ||||
| 888 | + | remaining, err := repo.GetAllRecentHistory(ctx) | |||
| 889 | + | require.NoError(t, err) | |||
| 890 | + | require.Len(t, remaining, 1) | |||
| 891 | + | assert.Equal(t, "https://other.test", remaining[0].URL) | |||
| 892 | + | } | |||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +47 -0
| @@ -149,6 +149,46 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int | |||||
| 149 | return r.repo.GetRecent(ctx, limit, offset) | 149 | return r.repo.GetRecent(ctx, limit, offset) | ||
| 150 | } | 150 | } | ||
| 151 | 151 | ||||
| 152 | + | func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |||
| 153 | + | if err := r.init(ctx); err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | return r.repo.GetRecentByDomain(ctx, domain, limit, offset) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) { | |||
| 160 | + | if err := r.init(ctx); err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | return r.repo.GetRecentWindow(ctx, before, after) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (r *LazyHistoryRepository) GetRecentWindowByDomain( | |||
| 167 | + | ctx context.Context, | |||
| 168 | + | domain string, | |||
| 169 | + | before time.Time, | |||
| 170 | + | after time.Time, | |||
| 171 | + | ) ([]*entity.HistoryEntry, error) { | |||
| 172 | + | if err := r.init(ctx); err != nil { | |||
| 173 | + | return nil, err | |||
| 174 | + | } | |||
| 175 | + | return r.repo.GetRecentWindowByDomain(ctx, domain, before, after) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) { | |||
| 179 | + | if err := r.init(ctx); err != nil { | |||
| 180 | + | return false, err | |||
| 181 | + | } | |||
| 182 | + | return r.repo.HasEntriesBefore(ctx, before) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) { | |||
| 186 | + | if err := r.init(ctx); err != nil { | |||
| 187 | + | return false, err | |||
| 188 | + | } | |||
| 189 | + | return r.repo.HasEntriesByDomainBefore(ctx, domain, before) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 152 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | 192 | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | ||
| 153 | if err := r.init(ctx); err != nil { | 193 | if err := r.init(ctx); err != nil { | ||
| 154 | return nil, err | 194 | return nil, err | ||
| @@ -223,6 +263,13 @@ func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time | |||||
| 223 | return r.repo.DeleteOlderThan(ctx, before) | 263 | return r.repo.DeleteOlderThan(ctx, before) | ||
| 224 | } | 264 | } | ||
| 225 | 265 | ||||
| 266 | + | func (r *LazyHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error { | |||
| 267 | + | if err := r.init(ctx); err != nil { | |||
| 268 | + | return err | |||
| 269 | + | } | |||
| 270 | + | return r.repo.DeleteSince(ctx, since) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 226 | func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error { | 273 | func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error { | ||
| 227 | if err := r.init(ctx); err != nil { | 274 | if err := r.init(ctx); err != nil { | ||
| 228 | return err | 275 | return err | ||
A internal/infrastructure/persistence/sqlite/migrations/008_history_domain.sql +90 -0
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | -- +goose Up | |||
| 2 | + | -- Store normalized history domains for indexed systemview filtering. | |||
| 3 | + | ||||
| 4 | + | ALTER TABLE history ADD COLUMN domain TEXT; | |||
| 5 | + | ||||
| 6 | + | UPDATE history | |||
| 7 | + | SET domain = LOWER( | |||
| 8 | + | CASE | |||
| 9 | + | WHEN INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') > 0 THEN | |||
| 10 | + | SUBSTR( | |||
| 11 | + | REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), | |||
| 12 | + | 1, | |||
| 13 | + | INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') - 1 | |||
| 14 | + | ) | |||
| 15 | + | ELSE REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/') | |||
| 16 | + | END | |||
| 17 | + | ) | |||
| 18 | + | WHERE INSTR(url, '://') > 0; | |||
| 19 | + | ||||
| 20 | + | -- Mirror domainurl.CanonicalDomain for legacy rows as closely as SQLite can: | |||
| 21 | + | -- strip userinfo first, then strip the leading www. alias. | |||
| 22 | + | UPDATE history | |||
| 23 | + | SET domain = SUBSTR(domain, INSTR(domain, '@') + 1) | |||
| 24 | + | WHERE INSTR(domain, '@') > 0; | |||
| 25 | + | ||||
| 26 | + | UPDATE history | |||
| 27 | + | SET domain = SUBSTR(domain, 5) | |||
| 28 | + | WHERE domain LIKE 'www.%'; | |||
| 29 | + | ||||
| 30 | + | CREATE INDEX IF NOT EXISTS idx_history_domain_last_visited ON history(domain, last_visited DESC); | |||
| 31 | + | ||||
| 32 | + | -- +goose Down | |||
| 33 | + | DROP INDEX IF EXISTS idx_history_domain_last_visited; | |||
| 34 | + | ||||
| 35 | + | -- Rebuild instead of ALTER TABLE DROP COLUMN so rollback works on SQLite | |||
| 36 | + | -- versions before 3.35. Preserve the base history schema and indexes/triggers. | |||
| 37 | + | DROP TRIGGER IF EXISTS history_fts_update; | |||
| 38 | + | DROP TRIGGER IF EXISTS history_fts_delete; | |||
| 39 | + | DROP TRIGGER IF EXISTS history_fts_insert; | |||
| 40 | + | DROP TABLE IF EXISTS history_fts; | |||
| 41 | + | ||||
| 42 | + | CREATE TABLE history_008_down ( | |||
| 43 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| 44 | + | url TEXT NOT NULL UNIQUE, | |||
| 45 | + | title TEXT, | |||
| 46 | + | favicon_url TEXT, | |||
| 47 | + | visit_count INTEGER DEFAULT 1, | |||
| 48 | + | last_visited DATETIME DEFAULT CURRENT_TIMESTAMP, | |||
| 49 | + | created_at DATETIME DEFAULT CURRENT_TIMESTAMP | |||
| 50 | + | ); | |||
| 51 | + | ||||
| 52 | + | INSERT INTO history_008_down (id, url, title, favicon_url, visit_count, last_visited, created_at) | |||
| 53 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at | |||
| 54 | + | FROM history; | |||
| 55 | + | ||||
| 56 | + | DROP TABLE history; | |||
| 57 | + | ALTER TABLE history_008_down RENAME TO history; | |||
| 58 | + | ||||
| 59 | + | CREATE INDEX IF NOT EXISTS idx_history_url ON history(url); | |||
| 60 | + | CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited); | |||
| 61 | + | CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited ON history(visit_count DESC, last_visited DESC); | |||
| 62 | + | ||||
| 63 | + | CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5( | |||
| 64 | + | url, | |||
| 65 | + | title, | |||
| 66 | + | content='history', | |||
| 67 | + | content_rowid='id' | |||
| 68 | + | ); | |||
| 69 | + | ||||
| 70 | + | -- +goose StatementBegin | |||
| 71 | + | CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN | |||
| 72 | + | INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title); | |||
| 73 | + | END; | |||
| 74 | + | -- +goose StatementEnd | |||
| 75 | + | ||||
| 76 | + | -- +goose StatementBegin | |||
| 77 | + | CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN | |||
| 78 | + | INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title); | |||
| 79 | + | END; | |||
| 80 | + | -- +goose StatementEnd | |||
| 81 | + | ||||
| 82 | + | -- +goose StatementBegin | |||
| 83 | + | CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN | |||
| 84 | + | INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title); | |||
| 85 | + | INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title); | |||
| 86 | + | END; | |||
| 87 | + | -- +goose StatementEnd | |||
| 88 | + | ||||
| 89 | + | INSERT INTO history_fts(rowid, url, title) | |||
| 90 | + | SELECT id, url, title FROM history; | |||
M internal/infrastructure/persistence/sqlite/queries/history.sql +66 -10
| @@ -1,18 +1,46 @@ | |||||
| 1 | -- name: GetRecentHistory :many | 1 | -- name: GetRecentHistory :many | ||
| 2 | SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?; | 2 | SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?; | ||
| 3 | 3 | ||||
| 4 | + | -- name: GetRecentHistoryByDomain :many | |||
| 5 | + | SELECT * FROM history | |||
| 6 | + | WHERE domain = @domain | |||
| 7 | + | ORDER BY last_visited DESC | |||
| 8 | + | LIMIT @limit OFFSET @offset; | |||
| 9 | + | ||||
| 10 | + | -- name: GetAllRecentHistoryByDomain :many | |||
| 11 | + | SELECT * FROM history | |||
| 12 | + | WHERE domain = @domain | |||
| 13 | + | ORDER BY last_visited DESC; | |||
| 14 | + | ||||
| 15 | + | -- name: GetRecentHistoryWindow :many | |||
| 16 | + | SELECT * FROM history | |||
| 17 | + | WHERE last_visited < @before AND last_visited >= @after | |||
| 18 | + | ORDER BY last_visited DESC; | |||
| 19 | + | ||||
| 20 | + | -- name: GetRecentHistoryWindowByDomain :many | |||
| 21 | + | SELECT * FROM history | |||
| 22 | + | WHERE domain = @domain AND last_visited < @before AND last_visited >= @after | |||
| 23 | + | ORDER BY last_visited DESC; | |||
| 24 | + | ||||
| 25 | + | -- name: HasHistoryBefore :one | |||
| 26 | + | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1); | |||
| 27 | + | ||||
| 28 | + | -- name: HasHistoryByDomainBefore :one | |||
| 29 | + | SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1); | |||
| 30 | + | ||||
| 4 | -- name: GetHistoryByURL :one | 31 | -- name: GetHistoryByURL :one | ||
| 5 | SELECT * FROM history WHERE url = ? LIMIT 1; | 32 | SELECT * FROM history WHERE url = ? LIMIT 1; | ||
| 6 | 33 | ||||
| 7 | -- name: UpsertHistory :exec | 34 | -- name: UpsertHistory :exec | ||
| 8 | - | INSERT INTO history (url, title, favicon_url) | 35 | + | INSERT INTO history (url, title, favicon_url, domain) |
| 9 | - | VALUES (?, ?, ?) | 36 | + | VALUES (?, ?, ?, ?) |
| 10 | ON CONFLICT(url) | 37 | ON CONFLICT(url) | ||
| 11 | DO UPDATE SET | 38 | DO UPDATE SET | ||
| 12 | visit_count = visit_count + 1, | 39 | visit_count = visit_count + 1, | ||
| 13 | last_visited = CURRENT_TIMESTAMP, | 40 | last_visited = CURRENT_TIMESTAMP, | ||
| 14 | title = COALESCE(EXCLUDED.title, history.title), | 41 | title = COALESCE(EXCLUDED.title, history.title), | ||
| 15 | - | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url); | 42 | + | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url), |
| 43 | + | domain = COALESCE(EXCLUDED.domain, history.domain); | |||
| 16 | 44 | ||||
| 17 | -- name: IncrementVisitCount :exec | 45 | -- name: IncrementVisitCount :exec | ||
| 18 | UPDATE history | 46 | UPDATE history | ||
| @@ -36,26 +64,54 @@ DELETE FROM history WHERE id = ?; | |||||
| 36 | -- name: DeleteHistoryOlderThan :exec | 64 | -- name: DeleteHistoryOlderThan :exec | ||
| 37 | DELETE FROM history WHERE last_visited < ?; | 65 | DELETE FROM history WHERE last_visited < ?; | ||
| 38 | 66 | ||||
| 67 | + | -- name: DeleteHistorySince :exec | |||
| 68 | + | DELETE FROM history WHERE last_visited >= ?; | |||
| 69 | + | ||||
| 39 | -- name: DeleteAllHistory :exec | 70 | -- name: DeleteAllHistory :exec | ||
| 40 | DELETE FROM history; | 71 | DELETE FROM history; | ||
| 41 | 72 | ||||
| 42 | -- name: GetHistoryStats :one | 73 | -- name: GetHistoryStats :one | ||
| 43 | - | SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history; | 74 | + | SELECT |
| 75 | + | COUNT(*) as total_entries, | |||
| 76 | + | COALESCE(SUM(visit_count), 0) as total_visits, | |||
| 77 | + | COUNT(DISTINCT date(last_visited)) as unique_days | |||
| 78 | + | FROM history; | |||
| 44 | 79 | ||||
| 45 | -- name: GetDomainStats :many | 80 | -- name: GetDomainStats :many | ||
| 46 | - | SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?; | 81 | + | SELECT |
| 82 | + | COALESCE(domain, '') as domain, | |||
| 83 | + | COUNT(*) as page_count, | |||
| 84 | + | SUM(visit_count) as total_visits, | |||
| 85 | + | MAX(last_visited) as last_visit | |||
| 86 | + | FROM history | |||
| 87 | + | WHERE domain IS NOT NULL AND domain != '' | |||
| 88 | + | GROUP BY domain | |||
| 89 | + | ORDER BY total_visits DESC | |||
| 90 | + | LIMIT ?; | |||
| 47 | 91 | ||||
| 48 | -- name: GetHourlyDistribution :many | 92 | -- name: GetHourlyDistribution :many | ||
| 49 | - | SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour; | 93 | + | SELECT |
| 94 | + | CAST(strftime('%H', last_visited) AS INTEGER) as hour, | |||
| 95 | + | COUNT(*) as visit_count | |||
| 96 | + | FROM history | |||
| 97 | + | GROUP BY hour | |||
| 98 | + | ORDER BY hour; | |||
| 50 | 99 | ||||
| 51 | -- name: GetDailyVisitCount :many | 100 | -- name: GetDailyVisitCount :many | ||
| 52 | - | SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC; | 101 | + | SELECT |
| 102 | + | date(last_visited) as day, | |||
| 103 | + | COUNT(*) as entries, | |||
| 104 | + | SUM(visit_count) as visits | |||
| 105 | + | FROM history | |||
| 106 | + | WHERE last_visited >= date('now', ?) | |||
| 107 | + | GROUP BY day | |||
| 108 | + | ORDER BY day ASC; | |||
| 53 | 109 | ||||
| 54 | -- name: DeleteHistoryByDomain :exec | 110 | -- name: DeleteHistoryByDomain :exec | ||
| 55 | - | DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?; | 111 | + | DELETE FROM history WHERE domain = @domain; |
| 56 | 112 | ||||
| 57 | -- name: SearchHistoryFTSUrl :many | 113 | -- name: SearchHistoryFTSUrl :many | ||
| 58 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 114 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain |
| 59 | FROM history_fts fts | 115 | FROM history_fts fts | ||
| 60 | JOIN history h ON fts.rowid = h.id | 116 | JOIN history h ON fts.rowid = h.id | ||
| 61 | WHERE fts.url MATCH @query | 117 | WHERE fts.url MATCH @query | ||
| @@ -78,7 +134,7 @@ ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC | |||||
| 78 | LIMIT @limit; | 134 | LIMIT @limit; | ||
| 79 | 135 | ||||
| 80 | -- name: SearchHistoryFTSTitle :many | 136 | -- name: SearchHistoryFTSTitle :many | ||
| 81 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 137 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain |
| 82 | FROM history_fts fts | 138 | FROM history_fts fts | ||
| 83 | JOIN history h ON fts.rowid = h.id | 139 | JOIN history h ON fts.rowid = h.id | ||
| 84 | WHERE fts.title MATCH @query | 140 | WHERE fts.title MATCH @query | ||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +267 -32
| @@ -35,23 +35,11 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error { | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec | 37 | const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec | ||
| 38 | - | DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ? | 38 | + | DELETE FROM history WHERE domain = ?1 |
| 39 | ` | 39 | ` | ||
| 40 | 40 | ||||
| 41 | - | type DeleteHistoryByDomainParams struct { | 41 | + | func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error { |
| 42 | - | Column1 sql.NullString `json:"column_1"` | 42 | + | _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain, domain) |
| 43 | - | Column2 sql.NullString `json:"column_2"` | |||
| 44 | - | Column3 sql.NullString `json:"column_3"` | |||
| 45 | - | Column4 sql.NullString `json:"column_4"` | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error { | |||
| 49 | - | _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain, | |||
| 50 | - | arg.Column1, | |||
| 51 | - | arg.Column2, | |||
| 52 | - | arg.Column3, | |||
| 53 | - | arg.Column4, | |||
| 54 | - | ) | |||
| 55 | return err | 43 | return err | ||
| 56 | } | 44 | } | ||
| 57 | 45 | ||||
| @@ -73,8 +61,17 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu | |||||
| 73 | return err | 61 | return err | ||
| 74 | } | 62 | } | ||
| 75 | 63 | ||||
| 64 | + | const DeleteHistorySince = `-- name: DeleteHistorySince :exec | |||
| 65 | + | DELETE FROM history WHERE last_visited >= ? | |||
| 66 | + | ` | |||
| 67 | + | ||||
| 68 | + | func (q *Queries) DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error { | |||
| 69 | + | _, err := q.db.ExecContext(ctx, DeleteHistorySince, lastVisited) | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | ||||
| 76 | const GetAllMostVisited = `-- name: GetAllMostVisited :many | 73 | const GetAllMostVisited = `-- name: GetAllMostVisited :many | ||
| 77 | - | 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 |
| 78 | ORDER BY visit_count DESC, last_visited DESC | 75 | ORDER BY visit_count DESC, last_visited DESC | ||
| 79 | ` | 76 | ` | ||
| 80 | 77 | ||||
| @@ -95,6 +92,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||||
| 95 | &i.VisitCount, | 92 | &i.VisitCount, | ||
| 96 | &i.LastVisited, | 93 | &i.LastVisited, | ||
| 97 | &i.CreatedAt, | 94 | &i.CreatedAt, | ||
| 95 | + | &i.Domain, | |||
| 98 | ); err != nil { | 96 | ); err != nil { | ||
| 99 | return nil, err | 97 | return nil, err | ||
| 100 | } | 98 | } | ||
| @@ -110,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) { | |||||
| 110 | } | 108 | } | ||
| 111 | 109 | ||||
| 112 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | 110 | const GetAllRecentHistory = `-- name: GetAllRecentHistory :many | ||
| 113 | - | 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 |
| 114 | ORDER BY last_visited DESC | 112 | ORDER BY last_visited DESC | ||
| 115 | ` | 113 | ` | ||
| 116 | 114 | ||||
| @@ -131,6 +129,45 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||||
| 131 | &i.VisitCount, | 129 | &i.VisitCount, | ||
| 132 | &i.LastVisited, | 130 | &i.LastVisited, | ||
| 133 | &i.CreatedAt, | 131 | &i.CreatedAt, | ||
| 132 | + | &i.Domain, | |||
| 133 | + | ); err != nil { | |||
| 134 | + | return nil, err | |||
| 135 | + | } | |||
| 136 | + | items = append(items, i) | |||
| 137 | + | } | |||
| 138 | + | if err := rows.Close(); err != nil { | |||
| 139 | + | return nil, err | |||
| 140 | + | } | |||
| 141 | + | if err := rows.Err(); err != nil { | |||
| 142 | + | return nil, err | |||
| 143 | + | } | |||
| 144 | + | return items, nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many | |||
| 148 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 149 | + | WHERE domain = ?1 | |||
| 150 | + | ORDER BY last_visited DESC | |||
| 151 | + | ` | |||
| 152 | + | ||||
| 153 | + | func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) { | |||
| 154 | + | rows, err := q.db.QueryContext(ctx, GetAllRecentHistoryByDomain, domain) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return nil, err | |||
| 157 | + | } | |||
| 158 | + | defer rows.Close() | |||
| 159 | + | items := []History{} | |||
| 160 | + | for rows.Next() { | |||
| 161 | + | var i History | |||
| 162 | + | if err := rows.Scan( | |||
| 163 | + | &i.ID, | |||
| 164 | + | &i.Url, | |||
| 165 | + | &i.Title, | |||
| 166 | + | &i.FaviconUrl, | |||
| 167 | + | &i.VisitCount, | |||
| 168 | + | &i.LastVisited, | |||
| 169 | + | &i.CreatedAt, | |||
| 170 | + | &i.Domain, | |||
| 134 | ); err != nil { | 171 | ); err != nil { | ||
| 135 | return nil, err | 172 | return nil, err | ||
| 136 | } | 173 | } | ||
| @@ -146,7 +183,14 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) { | |||||
| 146 | } | 183 | } | ||
| 147 | 184 | ||||
| 148 | const GetDailyVisitCount = `-- name: GetDailyVisitCount :many | 185 | const GetDailyVisitCount = `-- name: GetDailyVisitCount :many | ||
| 149 | - | SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC | 186 | + | SELECT |
| 187 | + | date(last_visited) as day, | |||
| 188 | + | COUNT(*) as entries, | |||
| 189 | + | SUM(visit_count) as visits | |||
| 190 | + | FROM history | |||
| 191 | + | WHERE last_visited >= date('now', ?) | |||
| 192 | + | GROUP BY day | |||
| 193 | + | ORDER BY day ASC | |||
| 150 | ` | 194 | ` | ||
| 151 | 195 | ||||
| 152 | type GetDailyVisitCountRow struct { | 196 | type GetDailyVisitCountRow struct { | ||
| @@ -179,7 +223,16 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G | |||||
| 179 | } | 223 | } | ||
| 180 | 224 | ||||
| 181 | const GetDomainStats = `-- name: GetDomainStats :many | 225 | const GetDomainStats = `-- name: GetDomainStats :many | ||
| 182 | - | SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ? | 226 | + | SELECT |
| 227 | + | COALESCE(domain, '') as domain, | |||
| 228 | + | COUNT(*) as page_count, | |||
| 229 | + | SUM(visit_count) as total_visits, | |||
| 230 | + | MAX(last_visited) as last_visit | |||
| 231 | + | FROM history | |||
| 232 | + | WHERE domain IS NOT NULL AND domain != '' | |||
| 233 | + | GROUP BY domain | |||
| 234 | + | ORDER BY total_visits DESC | |||
| 235 | + | LIMIT ? | |||
| 183 | ` | 236 | ` | ||
| 184 | 237 | ||||
| 185 | type GetDomainStatsRow struct { | 238 | type GetDomainStatsRow struct { | ||
| @@ -218,7 +271,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS | |||||
| 218 | } | 271 | } | ||
| 219 | 272 | ||||
| 220 | const GetHistoryByURL = `-- name: GetHistoryByURL :one | 273 | const GetHistoryByURL = `-- name: GetHistoryByURL :one | ||
| 221 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1 | 274 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history WHERE url = ? LIMIT 1 |
| 222 | ` | 275 | ` | ||
| 223 | 276 | ||||
| 224 | func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) { | 277 | func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) { | ||
| @@ -232,12 +285,17 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err | |||||
| 232 | &i.VisitCount, | 285 | &i.VisitCount, | ||
| 233 | &i.LastVisited, | 286 | &i.LastVisited, | ||
| 234 | &i.CreatedAt, | 287 | &i.CreatedAt, | ||
| 288 | + | &i.Domain, | |||
| 235 | ) | 289 | ) | ||
| 236 | return i, err | 290 | return i, err | ||
| 237 | } | 291 | } | ||
| 238 | 292 | ||||
| 239 | const GetHistoryStats = `-- name: GetHistoryStats :one | 293 | const GetHistoryStats = `-- name: GetHistoryStats :one | ||
| 240 | - | SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history | 294 | + | SELECT |
| 295 | + | COUNT(*) as total_entries, | |||
| 296 | + | COALESCE(SUM(visit_count), 0) as total_visits, | |||
| 297 | + | COUNT(DISTINCT date(last_visited)) as unique_days | |||
| 298 | + | FROM history | |||
| 241 | ` | 299 | ` | ||
| 242 | 300 | ||||
| 243 | type GetHistoryStatsRow struct { | 301 | type GetHistoryStatsRow struct { | ||
| @@ -254,7 +312,12 @@ func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, erro | |||||
| 254 | } | 312 | } | ||
| 255 | 313 | ||||
| 256 | const GetHourlyDistribution = `-- name: GetHourlyDistribution :many | 314 | const GetHourlyDistribution = `-- name: GetHourlyDistribution :many | ||
| 257 | - | SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour | 315 | + | SELECT |
| 316 | + | CAST(strftime('%H', last_visited) AS INTEGER) as hour, | |||
| 317 | + | COUNT(*) as visit_count | |||
| 318 | + | FROM history | |||
| 319 | + | GROUP BY hour | |||
| 320 | + | ORDER BY hour | |||
| 258 | ` | 321 | ` | ||
| 259 | 322 | ||||
| 260 | type GetHourlyDistributionRow struct { | 323 | type GetHourlyDistributionRow struct { | ||
| @@ -286,7 +349,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib | |||||
| 286 | } | 349 | } | ||
| 287 | 350 | ||||
| 288 | const GetMostVisited = `-- name: GetMostVisited :many | 351 | const GetMostVisited = `-- name: GetMostVisited :many | ||
| 289 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | 352 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history |
| 290 | WHERE last_visited >= datetime('now', ?) | 353 | WHERE last_visited >= datetime('now', ?) | ||
| 291 | ORDER BY visit_count DESC, last_visited DESC | 354 | ORDER BY visit_count DESC, last_visited DESC | ||
| 292 | ` | 355 | ` | ||
| @@ -308,6 +371,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||||
| 308 | &i.VisitCount, | 371 | &i.VisitCount, | ||
| 309 | &i.LastVisited, | 372 | &i.LastVisited, | ||
| 310 | &i.CreatedAt, | 373 | &i.CreatedAt, | ||
| 374 | + | &i.Domain, | |||
| 311 | ); err != nil { | 375 | ); err != nil { | ||
| 312 | return nil, err | 376 | return nil, err | ||
| 313 | } | 377 | } | ||
| @@ -323,7 +387,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H | |||||
| 323 | } | 387 | } | ||
| 324 | 388 | ||||
| 325 | const GetRecentHistory = `-- name: GetRecentHistory :many | 389 | const GetRecentHistory = `-- name: GetRecentHistory :many | ||
| 326 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | 390 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? |
| 327 | ` | 391 | ` | ||
| 328 | 392 | ||||
| 329 | type GetRecentHistoryParams struct { | 393 | type GetRecentHistoryParams struct { | ||
| @@ -348,6 +412,52 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 348 | &i.VisitCount, | 412 | &i.VisitCount, | ||
| 349 | &i.LastVisited, | 413 | &i.LastVisited, | ||
| 350 | &i.CreatedAt, | 414 | &i.CreatedAt, | ||
| 415 | + | &i.Domain, | |||
| 416 | + | ); err != nil { | |||
| 417 | + | return nil, err | |||
| 418 | + | } | |||
| 419 | + | items = append(items, i) | |||
| 420 | + | } | |||
| 421 | + | if err := rows.Close(); err != nil { | |||
| 422 | + | return nil, err | |||
| 423 | + | } | |||
| 424 | + | if err := rows.Err(); err != nil { | |||
| 425 | + | return nil, err | |||
| 426 | + | } | |||
| 427 | + | return items, nil | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many | |||
| 431 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 432 | + | WHERE domain = ?1 | |||
| 433 | + | ORDER BY last_visited DESC | |||
| 434 | + | LIMIT ?3 OFFSET ?2 | |||
| 435 | + | ` | |||
| 436 | + | ||||
| 437 | + | type GetRecentHistoryByDomainParams struct { | |||
| 438 | + | Domain sql.NullString `json:"domain"` | |||
| 439 | + | Offset int64 `json:"offset"` | |||
| 440 | + | Limit int64 `json:"limit"` | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) { | |||
| 444 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryByDomain, arg.Domain, arg.Offset, arg.Limit) | |||
| 445 | + | if err != nil { | |||
| 446 | + | return nil, err | |||
| 447 | + | } | |||
| 448 | + | defer rows.Close() | |||
| 449 | + | items := []History{} | |||
| 450 | + | for rows.Next() { | |||
| 451 | + | var i History | |||
| 452 | + | if err := rows.Scan( | |||
| 453 | + | &i.ID, | |||
| 454 | + | &i.Url, | |||
| 455 | + | &i.Title, | |||
| 456 | + | &i.FaviconUrl, | |||
| 457 | + | &i.VisitCount, | |||
| 458 | + | &i.LastVisited, | |||
| 459 | + | &i.CreatedAt, | |||
| 460 | + | &i.Domain, | |||
| 351 | ); err != nil { | 461 | ); err != nil { | ||
| 352 | return nil, err | 462 | return nil, err | ||
| 353 | } | 463 | } | ||
| @@ -363,7 +473,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 363 | } | 473 | } | ||
| 364 | 474 | ||||
| 365 | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | 475 | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | ||
| 366 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | 476 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history |
| 367 | WHERE last_visited >= datetime('now', ?) | 477 | WHERE last_visited >= datetime('now', ?) | ||
| 368 | ORDER BY last_visited DESC | 478 | ORDER BY last_visited DESC | ||
| 369 | ` | 479 | ` | ||
| @@ -385,6 +495,94 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||||
| 385 | &i.VisitCount, | 495 | &i.VisitCount, | ||
| 386 | &i.LastVisited, | 496 | &i.LastVisited, | ||
| 387 | &i.CreatedAt, | 497 | &i.CreatedAt, | ||
| 498 | + | &i.Domain, | |||
| 499 | + | ); err != nil { | |||
| 500 | + | return nil, err | |||
| 501 | + | } | |||
| 502 | + | items = append(items, i) | |||
| 503 | + | } | |||
| 504 | + | if err := rows.Close(); err != nil { | |||
| 505 | + | return nil, err | |||
| 506 | + | } | |||
| 507 | + | if err := rows.Err(); err != nil { | |||
| 508 | + | return nil, err | |||
| 509 | + | } | |||
| 510 | + | return items, nil | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many | |||
| 514 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 515 | + | WHERE last_visited < ?1 AND last_visited >= ?2 | |||
| 516 | + | ORDER BY last_visited DESC | |||
| 517 | + | ` | |||
| 518 | + | ||||
| 519 | + | type GetRecentHistoryWindowParams struct { | |||
| 520 | + | Before sql.NullTime `json:"before"` | |||
| 521 | + | After sql.NullTime `json:"after"` | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) { | |||
| 525 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After) | |||
| 526 | + | if err != nil { | |||
| 527 | + | return nil, err | |||
| 528 | + | } | |||
| 529 | + | defer rows.Close() | |||
| 530 | + | items := []History{} | |||
| 531 | + | for rows.Next() { | |||
| 532 | + | var i History | |||
| 533 | + | if err := rows.Scan( | |||
| 534 | + | &i.ID, | |||
| 535 | + | &i.Url, | |||
| 536 | + | &i.Title, | |||
| 537 | + | &i.FaviconUrl, | |||
| 538 | + | &i.VisitCount, | |||
| 539 | + | &i.LastVisited, | |||
| 540 | + | &i.CreatedAt, | |||
| 541 | + | &i.Domain, | |||
| 542 | + | ); err != nil { | |||
| 543 | + | return nil, err | |||
| 544 | + | } | |||
| 545 | + | items = append(items, i) | |||
| 546 | + | } | |||
| 547 | + | if err := rows.Close(); err != nil { | |||
| 548 | + | return nil, err | |||
| 549 | + | } | |||
| 550 | + | if err := rows.Err(); err != nil { | |||
| 551 | + | return nil, err | |||
| 552 | + | } | |||
| 553 | + | return items, nil | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many | |||
| 557 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history | |||
| 558 | + | WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3 | |||
| 559 | + | ORDER BY last_visited DESC | |||
| 560 | + | ` | |||
| 561 | + | ||||
| 562 | + | type GetRecentHistoryWindowByDomainParams struct { | |||
| 563 | + | Domain sql.NullString `json:"domain"` | |||
| 564 | + | Before sql.NullTime `json:"before"` | |||
| 565 | + | After sql.NullTime `json:"after"` | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) { | |||
| 569 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After) | |||
| 570 | + | if err != nil { | |||
| 571 | + | return nil, err | |||
| 572 | + | } | |||
| 573 | + | defer rows.Close() | |||
| 574 | + | items := []History{} | |||
| 575 | + | for rows.Next() { | |||
| 576 | + | var i History | |||
| 577 | + | if err := rows.Scan( | |||
| 578 | + | &i.ID, | |||
| 579 | + | &i.Url, | |||
| 580 | + | &i.Title, | |||
| 581 | + | &i.FaviconUrl, | |||
| 582 | + | &i.VisitCount, | |||
| 583 | + | &i.LastVisited, | |||
| 584 | + | &i.CreatedAt, | |||
| 585 | + | &i.Domain, | |||
| 388 | ); err != nil { | 586 | ); err != nil { | ||
| 389 | return nil, err | 587 | return nil, err | ||
| 390 | } | 588 | } | ||
| @@ -399,6 +597,33 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{ | |||||
| 399 | return items, nil | 597 | return items, nil | ||
| 400 | } | 598 | } | ||
| 401 | 599 | ||||
| 600 | + | const HasHistoryBefore = `-- name: HasHistoryBefore :one | |||
| 601 | + | SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1) | |||
| 602 | + | ` | |||
| 603 | + | ||||
| 604 | + | func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) { | |||
| 605 | + | row := q.db.QueryRowContext(ctx, HasHistoryBefore, before) | |||
| 606 | + | var column_1 int64 | |||
| 607 | + | err := row.Scan(&column_1) | |||
| 608 | + | return column_1, err | |||
| 609 | + | } | |||
| 610 | + | ||||
| 611 | + | const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one | |||
| 612 | + | SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1) | |||
| 613 | + | ` | |||
| 614 | + | ||||
| 615 | + | type HasHistoryByDomainBeforeParams struct { | |||
| 616 | + | Domain sql.NullString `json:"domain"` | |||
| 617 | + | Before sql.NullTime `json:"before"` | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) { | |||
| 621 | + | row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before) | |||
| 622 | + | var column_1 int64 | |||
| 623 | + | err := row.Scan(&column_1) | |||
| 624 | + | return column_1, err | |||
| 625 | + | } | |||
| 626 | + | ||||
| 402 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | 627 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | ||
| 403 | UPDATE history | 628 | UPDATE history | ||
| 404 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | 629 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | ||
| @@ -427,7 +652,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV | |||||
| 427 | } | 652 | } | ||
| 428 | 653 | ||||
| 429 | const SearchHistory = `-- name: SearchHistory :many | 654 | const SearchHistory = `-- name: SearchHistory :many | ||
| 430 | - | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | 655 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history |
| 431 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | 656 | WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%' | ||
| 432 | ORDER BY visit_count DESC, last_visited DESC | 657 | ORDER BY visit_count DESC, last_visited DESC | ||
| 433 | LIMIT ? | 658 | LIMIT ? | ||
| @@ -456,6 +681,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||||
| 456 | &i.VisitCount, | 681 | &i.VisitCount, | ||
| 457 | &i.LastVisited, | 682 | &i.LastVisited, | ||
| 458 | &i.CreatedAt, | 683 | &i.CreatedAt, | ||
| 684 | + | &i.Domain, | |||
| 459 | ); err != nil { | 685 | ); err != nil { | ||
| 460 | return nil, err | 686 | return nil, err | ||
| 461 | } | 687 | } | ||
| @@ -471,7 +697,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([ | |||||
| 471 | } | 697 | } | ||
| 472 | 698 | ||||
| 473 | const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many | 699 | const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many | ||
| 474 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 700 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain |
| 475 | FROM history_fts fts | 701 | FROM history_fts fts | ||
| 476 | JOIN history h ON fts.rowid = h.id | 702 | JOIN history h ON fts.rowid = h.id | ||
| 477 | WHERE fts.title MATCH ?1 | 703 | WHERE fts.title MATCH ?1 | ||
| @@ -501,6 +727,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT | |||||
| 501 | &i.VisitCount, | 727 | &i.VisitCount, | ||
| 502 | &i.LastVisited, | 728 | &i.LastVisited, | ||
| 503 | &i.CreatedAt, | 729 | &i.CreatedAt, | ||
| 730 | + | &i.Domain, | |||
| 504 | ); err != nil { | 731 | ); err != nil { | ||
| 505 | return nil, err | 732 | return nil, err | ||
| 506 | } | 733 | } | ||
| @@ -516,7 +743,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT | |||||
| 516 | } | 743 | } | ||
| 517 | 744 | ||||
| 518 | const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many | 745 | const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many | ||
| 519 | - | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at | 746 | + | SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain |
| 520 | FROM history_fts fts | 747 | FROM history_fts fts | ||
| 521 | JOIN history h ON fts.rowid = h.id | 748 | JOIN history h ON fts.rowid = h.id | ||
| 522 | WHERE fts.url MATCH ?1 | 749 | WHERE fts.url MATCH ?1 | ||
| @@ -546,6 +773,7 @@ func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSU | |||||
| 546 | &i.VisitCount, | 773 | &i.VisitCount, | ||
| 547 | &i.LastVisited, | 774 | &i.LastVisited, | ||
| 548 | &i.CreatedAt, | 775 | &i.CreatedAt, | ||
| 776 | + | &i.Domain, | |||
| 549 | ); err != nil { | 777 | ); err != nil { | ||
| 550 | return nil, err | 778 | return nil, err | ||
| 551 | } | 779 | } | ||
| @@ -626,23 +854,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se | |||||
| 626 | } | 854 | } | ||
| 627 | 855 | ||||
| 628 | const UpsertHistory = `-- name: UpsertHistory :exec | 856 | const UpsertHistory = `-- name: UpsertHistory :exec | ||
| 629 | - | INSERT INTO history (url, title, favicon_url) | 857 | + | INSERT INTO history (url, title, favicon_url, domain) |
| 630 | - | VALUES (?, ?, ?) | 858 | + | VALUES (?, ?, ?, ?) |
| 631 | ON CONFLICT(url) | 859 | ON CONFLICT(url) | ||
| 632 | DO UPDATE SET | 860 | DO UPDATE SET | ||
| 633 | visit_count = visit_count + 1, | 861 | visit_count = visit_count + 1, | ||
| 634 | last_visited = CURRENT_TIMESTAMP, | 862 | last_visited = CURRENT_TIMESTAMP, | ||
| 635 | title = COALESCE(EXCLUDED.title, history.title), | 863 | title = COALESCE(EXCLUDED.title, history.title), | ||
| 636 | - | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url) | 864 | + | favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url), |
| 865 | + | domain = COALESCE(EXCLUDED.domain, history.domain) | |||
| 637 | ` | 866 | ` | ||
| 638 | 867 | ||||
| 639 | type UpsertHistoryParams struct { | 868 | type UpsertHistoryParams struct { | ||
| 640 | Url string `json:"url"` | 869 | Url string `json:"url"` | ||
| 641 | Title sql.NullString `json:"title"` | 870 | Title sql.NullString `json:"title"` | ||
| 642 | FaviconUrl sql.NullString `json:"favicon_url"` | 871 | FaviconUrl sql.NullString `json:"favicon_url"` | ||
| 872 | + | Domain sql.NullString `json:"domain"` | |||
| 643 | } | 873 | } | ||
| 644 | 874 | ||||
| 645 | func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error { | 875 | func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error { | ||
| 646 | - | _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl) | 876 | + | _, err := q.db.ExecContext(ctx, UpsertHistory, |
| 877 | + | arg.Url, | |||
| 878 | + | arg.Title, | |||
| 879 | + | arg.FaviconUrl, | |||
| 880 | + | arg.Domain, | |||
| 881 | + | ) | |||
| 647 | return err | 882 | return err | ||
| 648 | } | 883 | } | ||
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
| @@ -56,6 +56,7 @@ type History struct { | |||||
| 56 | VisitCount sql.NullInt64 `json:"visit_count"` | 56 | VisitCount sql.NullInt64 `json:"visit_count"` | ||
| 57 | LastVisited sql.NullTime `json:"last_visited"` | 57 | LastVisited sql.NullTime `json:"last_visited"` | ||
| 58 | CreatedAt sql.NullTime `json:"created_at"` | 58 | CreatedAt sql.NullTime `json:"created_at"` | ||
| 59 | + | Domain sql.NullString `json:"domain"` | |||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | type HistoryFt struct { | 62 | type HistoryFt struct { | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +8 -1
| @@ -21,9 +21,10 @@ type Querier interface { | |||||
| 21 | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | 21 | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | ||
| 22 | DeleteFavorite(ctx context.Context, id int64) error | 22 | DeleteFavorite(ctx context.Context, id int64) error | ||
| 23 | DeleteFolder(ctx context.Context, id int64) error | 23 | DeleteFolder(ctx context.Context, id int64) error | ||
| 24 | - | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) 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 | ||
| @@ -36,6 +37,7 @@ type Querier interface { | |||||
| 36 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | 37 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | ||
| 37 | GetAllMostVisited(ctx context.Context) ([]History, error) | 38 | GetAllMostVisited(ctx context.Context) ([]History, error) | ||
| 38 | GetAllRecentHistory(ctx context.Context) ([]History, error) | 39 | GetAllRecentHistory(ctx context.Context) ([]History, error) | ||
| 40 | + | GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) | |||
| 39 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | 41 | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | ||
| 40 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | 42 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | ||
| 41 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | 43 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | ||
| @@ -54,7 +56,10 @@ type Querier interface { | |||||
| 54 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | 56 | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 55 | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | 57 | GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error) | ||
| 56 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | 58 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | ||
| 59 | + | GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) | |||
| 57 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | 60 | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | ||
| 61 | + | GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) | |||
| 62 | + | GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) | |||
| 58 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | 63 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | ||
| 59 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | 64 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | ||
| 60 | GetSessionByID(ctx context.Context, id string) (Session, error) | 65 | GetSessionByID(ctx context.Context, id string) (Session, error) | ||
| @@ -65,6 +70,8 @@ type Querier interface { | |||||
| 65 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | 70 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | ||
| 66 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | 71 | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | ||
| 67 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | 72 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | ||
| 73 | + | HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) | |||
| 74 | + | HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) | |||
| 68 | IncrementVisitCount(ctx context.Context, url string) error | 75 | IncrementVisitCount(ctx context.Context, url string) error | ||
| 69 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | 76 | IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error | ||
| 70 | InsertSession(ctx context.Context, arg InsertSessionParams) error | 77 | InsertSession(ctx context.Context, arg InsertSessionParams) error | ||
A internal/infrastructure/systemviewsbridge/browser_transport.go +133 -0
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package systemviewsbridge | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type browserRequest struct { | |||
| 11 | + | messageType string | |||
| 12 | + | requestID string | |||
| 13 | + | body []byte | |||
| 14 | + | directAPI bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func decodeBrowserRequest(body []byte) (browserRequest, error) { | |||
| 18 | + | var envelope struct { | |||
| 19 | + | Type string `json:"type"` | |||
| 20 | + | Payload json.RawMessage `json:"payload"` | |||
| 21 | + | } | |||
| 22 | + | if err := json.Unmarshal(body, &envelope); err != nil { | |||
| 23 | + | return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err) | |||
| 24 | + | } | |||
| 25 | + | if envelope.Type == "" { | |||
| 26 | + | return browserRequest{}, fmt.Errorf("browser request missing type") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | var payload struct { | |||
| 30 | + | RequestID string `json:"requestId"` | |||
| 31 | + | } | |||
| 32 | + | if len(envelope.Payload) != 0 { | |||
| 33 | + | if err := json.Unmarshal(envelope.Payload, &payload); err != nil { | |||
| 34 | + | return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | return browserRequest{ | |||
| 39 | + | messageType: envelope.Type, | |||
| 40 | + | requestID: payload.RequestID, | |||
| 41 | + | body: body, | |||
| 42 | + | directAPI: strings.HasPrefix(envelope.Type, "/api/"), | |||
| 43 | + | }, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 47 | + | return json.Marshal(bridgeResponse{ | |||
| 48 | + | RequestID: requestID, | |||
| 49 | + | Success: true, | |||
| 50 | + | Data: json.RawMessage(payload), | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // normalizeBridgeShimResponse accepts the bridge shapes emitted by native and | |||
| 55 | + | // fallback shims: a direct bridgeResponse envelope, a wrapper with data/error, | |||
| 56 | + | // or a wrapper whose data field itself contains a bridgeResponse envelope. | |||
| 57 | + | func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) { | |||
| 58 | + | trimmed := bytes.TrimSpace(body) | |||
| 59 | + | if len(trimmed) == 0 { | |||
| 60 | + | return nil, fmt.Errorf("empty bridge shim response") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | var topLevel map[string]json.RawMessage | |||
| 64 | + | if err := json.Unmarshal(trimmed, &topLevel); err != nil { | |||
| 65 | + | return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | _, hasSuccess := topLevel["success"] | |||
| 69 | + | _, hasRequestID := topLevel["requestId"] | |||
| 70 | + | _, hasCallback := topLevel["_callback"] | |||
| 71 | + | _, hasData := topLevel["data"] | |||
| 72 | + | ||||
| 73 | + | // Native callbacks can already return the final bridgeResponse shape. | |||
| 74 | + | if (hasSuccess || hasRequestID) && !hasCallback { | |||
| 75 | + | var resp bridgeResponse | |||
| 76 | + | if err := json.Unmarshal(trimmed, &resp); err != nil { | |||
| 77 | + | return nil, fmt.Errorf("unmarshal direct bridge response: %w", err) | |||
| 78 | + | } | |||
| 79 | + | if resp.RequestID == "" { | |||
| 80 | + | // Some direct shims omit requestId; preserve correlation for callers. | |||
| 81 | + | resp.RequestID = requestID | |||
| 82 | + | } | |||
| 83 | + | return json.Marshal(resp) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | var wrapped struct { | |||
| 87 | + | Data json.RawMessage `json:"data,omitempty"` | |||
| 88 | + | Error string `json:"error,omitempty"` | |||
| 89 | + | } | |||
| 90 | + | if err := json.Unmarshal(trimmed, &wrapped); err != nil { | |||
| 91 | + | return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err) | |||
| 92 | + | } | |||
| 93 | + | if wrapped.Error != "" { | |||
| 94 | + | // Fetch-style wrappers report transport errors outside the data payload. | |||
| 95 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error}) | |||
| 96 | + | } | |||
| 97 | + | if !hasData { | |||
| 98 | + | // Wrapped shim responses must carry either data or a top-level error. | |||
| 99 | + | return nil, fmt.Errorf("wrapped bridge response missing data") | |||
| 100 | + | } | |||
| 101 | + | if len(wrapped.Data) == 0 { | |||
| 102 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | var nestedFields map[string]json.RawMessage | |||
| 106 | + | if err := json.Unmarshal(wrapped.Data, &nestedFields); err == nil { | |||
| 107 | + | nestedSuccess, nestedHasSuccess := nestedFields["success"] | |||
| 108 | + | _, nestedHasRequestID := nestedFields["requestId"] | |||
| 109 | + | _, nestedHasError := nestedFields["error"] | |||
| 110 | + | successIsBool := false | |||
| 111 | + | if nestedHasSuccess { | |||
| 112 | + | var successValue bool | |||
| 113 | + | successIsBool = json.Unmarshal(nestedSuccess, &successValue) == nil | |||
| 114 | + | } | |||
| 115 | + | hasNestedEnvelope := successIsBool || (nestedHasRequestID && nestedHasError) | |||
| 116 | + | if hasNestedEnvelope { | |||
| 117 | + | var nested bridgeResponse | |||
| 118 | + | if err := json.Unmarshal(wrapped.Data, &nested); err == nil { | |||
| 119 | + | if nested.RequestID == "" { | |||
| 120 | + | // Nested envelopes from direct API wrappers also need caller correlation. | |||
| 121 | + | nested.RequestID = requestID | |||
| 122 | + | } | |||
| 123 | + | return json.Marshal(nested) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | return json.Marshal(bridgeResponse{ | |||
| 129 | + | RequestID: requestID, | |||
| 130 | + | Success: true, | |||
| 131 | + | Data: wrapped.Data, | |||
| 132 | + | }) | |||
| 133 | + | } | |||
A internal/infrastructure/systemviewsbridge/browser_transport_js.go +484 -0
| @@ -0,0 +1,484 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviewsbridge | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "strings" | |||
| 12 | + | "sync" | |||
| 13 | + | "syscall/js" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type callbackPlan struct { | |||
| 17 | + | success string | |||
| 18 | + | failure string | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func NewBrowserClient() *Client { | |||
| 22 | + | return NewClient(&webkitTransport{}, &browserTransport{}) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | |||
| 26 | + | switch msgType { | |||
| 27 | + | case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics", | |||
| 28 | + | "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list", | |||
| 29 | + | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | |||
| 30 | + | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | |||
| 31 | + | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | |||
| 32 | + | case "save_config": | |||
| 33 | + | return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true | |||
| 34 | + | case "get_keybindings": | |||
| 35 | + | return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true | |||
| 36 | + | case "set_keybinding": | |||
| 37 | + | return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true | |||
| 38 | + | case "reset_keybinding": | |||
| 39 | + | return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true | |||
| 40 | + | case "reset_all_keybindings": | |||
| 41 | + | return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true | |||
| 42 | + | default: | |||
| 43 | + | return callbackPlan{}, false | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | type browserTransport struct{} | |||
| 48 | + | ||||
| 49 | + | func (*browserTransport) Available() bool { | |||
| 50 | + | window := js.Global().Get("window") | |||
| 51 | + | if !window.Truthy() { | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | bridge := window.Get("dumber") | |||
| 55 | + | return bridge.Truthy() && bridge.Get("postMessage").Truthy() | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |||
| 59 | + | req, err := decodeBrowserRequest(body) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 63 | + | if req.directAPI { | |||
| 64 | + | return fetchDirectAPI(ctx, req.requestID, req.messageType) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | window := js.Global().Get("window") | |||
| 68 | + | bridge := window.Get("dumber") | |||
| 69 | + | if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() { | |||
| 70 | + | return nil, errors.New("browser bridge shim not available") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |||
| 74 | + | value, err := awaitPromise(ctx, promise) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return nil, err | |||
| 77 | + | } | |||
| 78 | + | raw, err := stringifyValue(value) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return nil, err | |||
| 81 | + | } | |||
| 82 | + | return normalizeBridgeShimResponse(req.requestID, raw) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | type webkitTransport struct{} | |||
| 86 | + | ||||
| 87 | + | type webkitCallbackResult struct { | |||
| 88 | + | body []byte | |||
| 89 | + | err error | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | type webkitCallbackWaiter struct { | |||
| 93 | + | requestID string | |||
| 94 | + | successName string | |||
| 95 | + | failureName string | |||
| 96 | + | resultCh chan webkitCallbackResult | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // webkitCallbacks uses one page-lifetime dispatcher per native callback name. | |||
| 100 | + | // Multiple requests may share callback names concurrently. When native payloads | |||
| 101 | + | // include requestId, dispatch to that waiter; older callbacks without requestId | |||
| 102 | + | // are resolved FIFO so they cannot target released js.Func handles. | |||
| 103 | + | var webkitCallbacks = struct { | |||
| 104 | + | sync.Mutex | |||
| 105 | + | installed map[string]js.Func | |||
| 106 | + | waiters map[string][]*webkitCallbackWaiter | |||
| 107 | + | }{ | |||
| 108 | + | installed: make(map[string]js.Func), | |||
| 109 | + | waiters: make(map[string][]*webkitCallbackWaiter), | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func (*webkitTransport) Available() bool { | |||
| 113 | + | window := js.Global().Get("window") | |||
| 114 | + | if !window.Truthy() { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | webkit := window.Get("webkit") | |||
| 118 | + | if !webkit.Truthy() { | |||
| 119 | + | return false | |||
| 120 | + | } | |||
| 121 | + | handlers := webkit.Get("messageHandlers") | |||
| 122 | + | if !handlers.Truthy() { | |||
| 123 | + | return false | |||
| 124 | + | } | |||
| 125 | + | return handlers.Get("dumber").Truthy() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |||
| 129 | + | req, err := decodeBrowserRequest(body) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | if req.directAPI { | |||
| 134 | + | return fetchDirectAPI(ctx, req.requestID, req.messageType) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | plan, ok := callbackPlanForMessage(req.messageType) | |||
| 138 | + | if !ok { | |||
| 139 | + | return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | window := js.Global().Get("window") | |||
| 143 | + | handler := window.Get("webkit").Get("messageHandlers").Get("dumber") | |||
| 144 | + | if !handler.Truthy() { | |||
| 145 | + | return nil, errors.New("webkit message handler not available") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | ensureWebKitCallback(window, plan.success, false) | |||
| 149 | + | ensureWebKitCallback(window, plan.failure, true) | |||
| 150 | + | ||||
| 151 | + | resultCh := make(chan webkitCallbackResult, 1) | |||
| 152 | + | unregister, err := registerWebKitWaiter(req.requestID, plan, resultCh) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |||
| 158 | + | ||||
| 159 | + | select { | |||
| 160 | + | case res := <-resultCh: | |||
| 161 | + | return res.body, res.err | |||
| 162 | + | case <-ctx.Done(): | |||
| 163 | + | unregister() | |||
| 164 | + | return nil, ctx.Err() | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func ensureWebKitCallback(window js.Value, name string, failure bool) { | |||
| 169 | + | webkitCallbacks.Lock() | |||
| 170 | + | if _, ok := webkitCallbacks.installed[name]; ok { | |||
| 171 | + | webkitCallbacks.Unlock() | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | callback := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 175 | + | handleWebKitCallback(name, failure, args) | |||
| 176 | + | return nil | |||
| 177 | + | }) | |||
| 178 | + | webkitCallbacks.installed[name] = callback | |||
| 179 | + | // Installed callbacks are page-lifetime dispatchers. Per-request waiters are | |||
| 180 | + | // removed on completion/cancellation, so late native responses are dropped | |||
| 181 | + | // without invoking released js.Func handles. | |||
| 182 | + | window.Set(name, callback) | |||
| 183 | + | webkitCallbacks.Unlock() | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan webkitCallbackResult) (func(), error) { | |||
| 187 | + | waiter := &webkitCallbackWaiter{ | |||
| 188 | + | requestID: requestID, | |||
| 189 | + | successName: plan.success, | |||
| 190 | + | failureName: plan.failure, | |||
| 191 | + | resultCh: resultCh, | |||
| 192 | + | } | |||
| 193 | + | webkitCallbacks.Lock() | |||
| 194 | + | defer webkitCallbacks.Unlock() | |||
| 195 | + | webkitCallbacks.waiters[plan.success] = append(webkitCallbacks.waiters[plan.success], waiter) | |||
| 196 | + | webkitCallbacks.waiters[plan.failure] = append(webkitCallbacks.waiters[plan.failure], waiter) | |||
| 197 | + | return func() { | |||
| 198 | + | webkitCallbacks.Lock() | |||
| 199 | + | defer webkitCallbacks.Unlock() | |||
| 200 | + | removeWebKitWaiterLocked(waiter) | |||
| 201 | + | }, nil | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func findWebKitWaiterLocked(name, requestID string) *webkitCallbackWaiter { | |||
| 205 | + | waiters := webkitCallbacks.waiters[name] | |||
| 206 | + | if len(waiters) == 0 { | |||
| 207 | + | return nil | |||
| 208 | + | } | |||
| 209 | + | if requestID == "" { | |||
| 210 | + | return waiters[0] | |||
| 211 | + | } | |||
| 212 | + | for _, waiter := range waiters { | |||
| 213 | + | if waiter != nil && waiter.requestID == requestID { | |||
| 214 | + | return waiter | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func removeWebKitWaiterLocked(waiter *webkitCallbackWaiter) { | |||
| 221 | + | if waiter == nil { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | remove := func(name string) { | |||
| 225 | + | waiters := webkitCallbacks.waiters[name] | |||
| 226 | + | for i, candidate := range waiters { | |||
| 227 | + | if candidate != waiter { | |||
| 228 | + | continue | |||
| 229 | + | } | |||
| 230 | + | waiters = append(waiters[:i], waiters[i+1:]...) | |||
| 231 | + | if len(waiters) == 0 { | |||
| 232 | + | delete(webkitCallbacks.waiters, name) | |||
| 233 | + | } else { | |||
| 234 | + | webkitCallbacks.waiters[name] = waiters | |||
| 235 | + | } | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | remove(waiter.successName) | |||
| 240 | + | remove(waiter.failureName) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func handleWebKitCallback(name string, failure bool, args []js.Value) { | |||
| 244 | + | payload, payloadErr := stringifyArgs(args) | |||
| 245 | + | payloadRequestID := requestIDFromBridgePayload(payload) | |||
| 246 | + | ||||
| 247 | + | webkitCallbacks.Lock() | |||
| 248 | + | waiter := findWebKitWaiterLocked(name, payloadRequestID) | |||
| 249 | + | if waiter != nil { | |||
| 250 | + | removeWebKitWaiterLocked(waiter) | |||
| 251 | + | } | |||
| 252 | + | webkitCallbacks.Unlock() | |||
| 253 | + | ||||
| 254 | + | if waiter == nil { | |||
| 255 | + | warnDroppedBridgeResult("webkit callback without waiter", nil) | |||
| 256 | + | return | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | res := webkitCallbackResult{err: payloadErr} | |||
| 260 | + | if payloadErr == nil { | |||
| 261 | + | if failure { | |||
| 262 | + | res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload) | |||
| 263 | + | } else { | |||
| 264 | + | res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | select { | |||
| 268 | + | case waiter.resultCh <- res: | |||
| 269 | + | default: | |||
| 270 | + | warnDroppedBridgeResult("webkit callback", res.err) | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func requestIDFromBridgePayload(payload []byte) string { | |||
| 275 | + | if len(bytes.TrimSpace(payload)) == 0 { | |||
| 276 | + | return "" | |||
| 277 | + | } | |||
| 278 | + | var envelope struct { | |||
| 279 | + | RequestID string `json:"requestId"` | |||
| 280 | + | } | |||
| 281 | + | if err := json.Unmarshal(payload, &envelope); err != nil { | |||
| 282 | + | return "" | |||
| 283 | + | } | |||
| 284 | + | return envelope.RequestID | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) { | |||
| 288 | + | fetch := js.Global().Get("fetch") | |||
| 289 | + | if !fetch.Truthy() { | |||
| 290 | + | return nil, errors.New("fetch API not available") | |||
| 291 | + | } | |||
| 292 | + | response, err := awaitPromise(ctx, fetch.Invoke(endpoint)) | |||
| 293 | + | if err != nil { | |||
| 294 | + | return nil, err | |||
| 295 | + | } | |||
| 296 | + | if !response.Get("ok").Bool() { | |||
| 297 | + | status := response.Get("status").Int() | |||
| 298 | + | statusText := response.Get("statusText").String() | |||
| 299 | + | if statusText == "" { | |||
| 300 | + | statusText = "request failed" | |||
| 301 | + | } | |||
| 302 | + | return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText) | |||
| 303 | + | } | |||
| 304 | + | data, err := awaitPromise(ctx, response.Call("json")) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, err | |||
| 307 | + | } | |||
| 308 | + | raw, err := stringifyValue(data) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return nil, err | |||
| 311 | + | } | |||
| 312 | + | return wrapDirectAPIResponse(requestID, raw) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 316 | + | trimmed := bytes.TrimSpace(payload) | |||
| 317 | + | if len(trimmed) == 0 { | |||
| 318 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | var resp bridgeResponse | |||
| 322 | + | if err := json.Unmarshal(trimmed, &resp); err == nil { | |||
| 323 | + | hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != "" | |||
| 324 | + | if hasEnvelope { | |||
| 325 | + | if resp.RequestID == "" { | |||
| 326 | + | resp.RequestID = requestID | |||
| 327 | + | } | |||
| 328 | + | return json.Marshal(resp) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)}) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 336 | + | trimmed := bytes.TrimSpace(payload) | |||
| 337 | + | var message string | |||
| 338 | + | if len(trimmed) == 0 { | |||
| 339 | + | message = "bridge request failed" | |||
| 340 | + | } else if err := json.Unmarshal(trimmed, &message); err != nil { | |||
| 341 | + | message = strings.Trim(string(trimmed), `"`) | |||
| 342 | + | } | |||
| 343 | + | if message == "" { | |||
| 344 | + | message = "bridge request failed" | |||
| 345 | + | } | |||
| 346 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Error: message}) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | |||
| 350 | + | if ctx == nil { | |||
| 351 | + | ctx = context.Background() | |||
| 352 | + | } | |||
| 353 | + | type result struct { | |||
| 354 | + | value js.Value | |||
| 355 | + | err error | |||
| 356 | + | } | |||
| 357 | + | resultCh := make(chan result, 1) | |||
| 358 | + | ||||
| 359 | + | promiseCtor := js.Global().Get("Promise") | |||
| 360 | + | if !promiseCtor.Truthy() { | |||
| 361 | + | return js.Undefined(), errors.New("Promise global not available") | |||
| 362 | + | } | |||
| 363 | + | var ( | |||
| 364 | + | cancelMu sync.Mutex | |||
| 365 | + | reject = js.Undefined() | |||
| 366 | + | ) | |||
| 367 | + | executor := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 368 | + | if len(args) >= 2 { | |||
| 369 | + | cancelMu.Lock() | |||
| 370 | + | reject = args[1] | |||
| 371 | + | cancelMu.Unlock() | |||
| 372 | + | } | |||
| 373 | + | return nil | |||
| 374 | + | }) | |||
| 375 | + | cancelPromise := promiseCtor.New(executor) | |||
| 376 | + | executor.Release() | |||
| 377 | + | raceInput := js.Global().Get("Array").New(promise, cancelPromise) | |||
| 378 | + | racePromise := promiseCtor.Call("race", raceInput) | |||
| 379 | + | ||||
| 380 | + | done := make(chan struct{}) | |||
| 381 | + | go func() { | |||
| 382 | + | select { | |||
| 383 | + | case <-ctx.Done(): | |||
| 384 | + | cancelMu.Lock() | |||
| 385 | + | rejectFn := reject | |||
| 386 | + | cancelMu.Unlock() | |||
| 387 | + | if rejectFn.Truthy() { | |||
| 388 | + | rejectFn.Invoke(ctx.Err().Error()) | |||
| 389 | + | } | |||
| 390 | + | case <-done: | |||
| 391 | + | } | |||
| 392 | + | }() | |||
| 393 | + | defer close(done) | |||
| 394 | + | ||||
| 395 | + | var ( | |||
| 396 | + | callbackMu sync.Mutex | |||
| 397 | + | released bool | |||
| 398 | + | thenCb js.Func | |||
| 399 | + | catchCb js.Func | |||
| 400 | + | ) | |||
| 401 | + | releaseCallbacks := func() { | |||
| 402 | + | callbackMu.Lock() | |||
| 403 | + | defer callbackMu.Unlock() | |||
| 404 | + | if released { | |||
| 405 | + | return | |||
| 406 | + | } | |||
| 407 | + | released = true | |||
| 408 | + | thenCb.Release() | |||
| 409 | + | catchCb.Release() | |||
| 410 | + | } | |||
| 411 | + | publish := func(res result) { | |||
| 412 | + | select { | |||
| 413 | + | case resultCh <- res: | |||
| 414 | + | default: | |||
| 415 | + | warnDroppedBridgeResult("promise", res.err) | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 420 | + | defer releaseCallbacks() | |||
| 421 | + | if len(args) == 0 { | |||
| 422 | + | publish(result{value: js.Undefined()}) | |||
| 423 | + | return nil | |||
| 424 | + | } | |||
| 425 | + | publish(result{value: args[0]}) | |||
| 426 | + | return nil | |||
| 427 | + | }) | |||
| 428 | + | catchCb = js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 429 | + | defer releaseCallbacks() | |||
| 430 | + | if len(args) == 0 { | |||
| 431 | + | publish(result{err: errors.New("promise rejected")}) | |||
| 432 | + | return nil | |||
| 433 | + | } | |||
| 434 | + | message := args[0].String() | |||
| 435 | + | if ctxErr := ctx.Err(); ctxErr != nil && message == ctxErr.Error() { | |||
| 436 | + | publish(result{err: ctxErr}) | |||
| 437 | + | return nil | |||
| 438 | + | } | |||
| 439 | + | publish(result{err: errors.New(message)}) | |||
| 440 | + | return nil | |||
| 441 | + | }) | |||
| 442 | + | attachPromiseCallbacks(racePromise, thenCb, catchCb) | |||
| 443 | + | ||||
| 444 | + | res := <-resultCh | |||
| 445 | + | return res.value, res.err | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) { | |||
| 449 | + | promise.Call("then", thenCb, catchCb) | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func warnDroppedBridgeResult(scope string, err error) { | |||
| 453 | + | console := js.Global().Get("console") | |||
| 454 | + | if !console.Truthy() || !console.Get("warn").Truthy() { | |||
| 455 | + | return | |||
| 456 | + | } | |||
| 457 | + | if err != nil { | |||
| 458 | + | console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error()) | |||
| 459 | + | return | |||
| 460 | + | } | |||
| 461 | + | console.Call("warn", "dumber systemviews bridge dropped "+scope+" result") | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func stringifyArgs(args []js.Value) ([]byte, error) { | |||
| 465 | + | if len(args) == 0 { | |||
| 466 | + | return nil, nil | |||
| 467 | + | } | |||
| 468 | + | return stringifyValue(args[0]) | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func stringifyValue(value js.Value) ([]byte, error) { | |||
| 472 | + | jsonValue := js.Global().Get("JSON") | |||
| 473 | + | if !jsonValue.Truthy() { | |||
| 474 | + | return nil, errors.New("JSON global not available") | |||
| 475 | + | } | |||
| 476 | + | stringified := jsonValue.Call("stringify", value) | |||
| 477 | + | if stringified.Type() == js.TypeString { | |||
| 478 | + | return []byte(stringified.String()), nil | |||
| 479 | + | } | |||
| 480 | + | if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) { | |||
| 481 | + | return []byte("null"), nil | |||
| 482 | + | } | |||
| 483 | + | return nil, errors.New("failed to stringify JS value") | |||
| 484 | + | } | |||
A internal/infrastructure/systemviewsbridge/browser_transport_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package systemviewsbridge | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) { | |||
| 10 | + | t.Parallel() | |||
| 11 | + | ||||
| 12 | + | body, err := buildMessageEnvelope("/api/config", struct { | |||
| 13 | + | RequestID string `json:"requestId"` | |||
| 14 | + | }{RequestID: "req-1"}) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | req, err := decodeBrowserRequest(body) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("decodeBrowserRequest() error = %v", err) | |||
| 22 | + | } | |||
| 23 | + | if req.messageType != "/api/config" { | |||
| 24 | + | t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config") | |||
| 25 | + | } | |||
| 26 | + | if req.requestID != "req-1" { | |||
| 27 | + | t.Fatalf("requestID = %q, want %q", req.requestID, "req-1") | |||
| 28 | + | } | |||
| 29 | + | if !req.directAPI { | |||
| 30 | + | t.Fatal("directAPI = false, want true") | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) { | |||
| 35 | + | t.Parallel() | |||
| 36 | + | ||||
| 37 | + | body, err := buildMessageEnvelope("history_timeline", struct { | |||
| 38 | + | RequestID string `json:"requestId"` | |||
| 39 | + | Limit int `json:"limit"` | |||
| 40 | + | }{RequestID: "req-2", Limit: 25}) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | req, err := decodeBrowserRequest(body) | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatalf("decodeBrowserRequest() error = %v", err) | |||
| 48 | + | } | |||
| 49 | + | if req.messageType != "history_timeline" { | |||
| 50 | + | t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline") | |||
| 51 | + | } | |||
| 52 | + | if req.requestID != "req-2" { | |||
| 53 | + | t.Fatalf("requestID = %q, want %q", req.requestID, "req-2") | |||
| 54 | + | } | |||
| 55 | + | if req.directAPI { | |||
| 56 | + | t.Fatal("directAPI = true, want false") | |||
| 57 | + | } | |||
| 58 | + | if !bytes.Equal(req.body, body) { | |||
| 59 | + | t.Fatalf("body = %s, want %s", req.body, body) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) { | |||
| 64 | + | t.Parallel() | |||
| 65 | + | ||||
| 66 | + | got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`)) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | var resp bridgeResponse | |||
| 72 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 73 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 74 | + | } | |||
| 75 | + | if !resp.Success { | |||
| 76 | + | t.Fatal("success = false, want true") | |||
| 77 | + | } | |||
| 78 | + | if resp.RequestID != "req-3" { | |||
| 79 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3") | |||
| 80 | + | } | |||
| 81 | + | if string(resp.Data) != `[{"title":"Example"}]` { | |||
| 82 | + | t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestNormalizeBridgeShimResponse_DoesNotTreatRequestIDDataAsEnvelope(t *testing.T) { | |||
| 87 | + | t.Parallel() | |||
| 88 | + | ||||
| 89 | + | got, err := normalizeBridgeShimResponse("req-raw", []byte(`{"data":{"requestId":"external-id","title":"Example"},"_callback":"__dumber_homepage_response"}`)) | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | var resp bridgeResponse | |||
| 95 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 96 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 97 | + | } | |||
| 98 | + | if string(resp.Data) != `{"requestId":"external-id","title":"Example"}` { | |||
| 99 | + | t.Fatalf("data = %s, want raw payload", resp.Data) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestNormalizeBridgeShimResponse_DoesNotTreatNonBooleanSuccessDataAsEnvelope(t *testing.T) { | |||
| 104 | + | t.Parallel() | |||
| 105 | + | ||||
| 106 | + | got, err := normalizeBridgeShimResponse("req-raw-success", []byte(`{"data":{"success":"yes","title":"Example"},"_callback":"__dumber_homepage_response"}`)) | |||
| 107 | + | if err != nil { | |||
| 108 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | var resp bridgeResponse | |||
| 112 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 113 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 114 | + | } | |||
| 115 | + | if string(resp.Data) != `{"success":"yes","title":"Example"}` { | |||
| 116 | + | t.Fatalf("data = %s, want raw payload", resp.Data) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | |||
| 121 | + | t.Parallel() | |||
| 122 | + | ||||
| 123 | + | got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`)) | |||
| 124 | + | if err != nil { | |||
| 125 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | var resp bridgeResponse | |||
| 129 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 130 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 131 | + | } | |||
| 132 | + | if !resp.Success { | |||
| 133 | + | t.Fatal("success = false, want true") | |||
| 134 | + | } | |||
| 135 | + | if resp.RequestID != "req-4" { | |||
| 136 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4") | |||
| 137 | + | } | |||
| 138 | + | if string(resp.Data) != `{"status":"success"}` { | |||
| 139 | + | t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestNormalizeBridgeShimResponse_WrapsTopLevelErrorWithoutData(t *testing.T) { | |||
| 144 | + | t.Parallel() | |||
| 145 | + | ||||
| 146 | + | got, err := normalizeBridgeShimResponse("req-error", []byte(`{"error":"bridge failed"}`)) | |||
| 147 | + | if err != nil { | |||
| 148 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | var resp bridgeResponse | |||
| 152 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 153 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 154 | + | } | |||
| 155 | + | if resp.RequestID != "req-error" { | |||
| 156 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-error") | |||
| 157 | + | } | |||
| 158 | + | if resp.Error != "bridge failed" { | |||
| 159 | + | t.Fatalf("error = %q, want %q", resp.Error, "bridge failed") | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func TestNormalizeBridgeShimResponse_RejectsMalformedJSON(t *testing.T) { | |||
| 164 | + | t.Parallel() | |||
| 165 | + | ||||
| 166 | + | if _, err := normalizeBridgeShimResponse("req-bad", []byte(`{"data":`)); err == nil { | |||
| 167 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestNormalizeBridgeShimResponse_RejectsMissingData(t *testing.T) { | |||
| 172 | + | t.Parallel() | |||
| 173 | + | ||||
| 174 | + | if _, err := normalizeBridgeShimResponse("req-missing", []byte(`{"_callback":"__dumber_config_saved"}`)); err == nil { | |||
| 175 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestNormalizeBridgeShimResponse_RejectsEmptyBody(t *testing.T) { | |||
| 180 | + | t.Parallel() | |||
| 181 | + | ||||
| 182 | + | if _, err := normalizeBridgeShimResponse("req-empty", nil); err == nil { | |||
| 183 | + | t.Fatal("normalizeBridgeShimResponse() error = nil, want error") | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) { | |||
| 188 | + | t.Parallel() | |||
| 189 | + | ||||
| 190 | + | got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`)) | |||
| 191 | + | if err != nil { | |||
| 192 | + | t.Fatalf("wrapDirectAPIResponse() error = %v", err) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | var resp bridgeResponse | |||
| 196 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 197 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 198 | + | } | |||
| 199 | + | if !resp.Success { | |||
| 200 | + | t.Fatal("success = false, want true") | |||
| 201 | + | } | |||
| 202 | + | if resp.RequestID != "req-5" { | |||
| 203 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5") | |||
| 204 | + | } | |||
| 205 | + | if string(resp.Data) != `{"engine_type":"webkit"}` { | |||
| 206 | + | t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`) | |||
| 207 | + | } | |||
| 208 | + | } | |||
A internal/infrastructure/systemviewsbridge/client.go +573 -0
| @@ -0,0 +1,573 @@ | |||||
| 1 | + | package systemviewsbridge | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Transport sends a WebUI message envelope and returns the raw response body. | |||
| 18 | + | type Transport interface { | |||
| 19 | + | Available() bool | |||
| 20 | + | Send(ctx context.Context, body []byte) ([]byte, error) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Client sends WebUI message envelopes through the first available transport. | |||
| 24 | + | type Client struct { | |||
| 25 | + | native Transport | |||
| 26 | + | fetch Transport | |||
| 27 | + | requestTimeout time.Duration | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | var _ port.SystemviewConfigService = (*Client)(nil) | |||
| 31 | + | var _ port.SystemviewHistoryService = (*Client)(nil) | |||
| 32 | + | var _ port.SystemviewFavoritesService = (*Client)(nil) | |||
| 33 | + | ||||
| 34 | + | var requestSeq atomic.Uint64 | |||
| 35 | + | ||||
| 36 | + | const defaultBridgeRequestTimeout = 15 * time.Second | |||
| 37 | + | ||||
| 38 | + | // ClientOption customizes a bridge client. | |||
| 39 | + | type ClientOption func(*Client) | |||
| 40 | + | ||||
| 41 | + | // WithRequestTimeout sets the timeout used when callers provide no deadline. | |||
| 42 | + | func WithRequestTimeout(timeout time.Duration) ClientOption { | |||
| 43 | + | return func(c *Client) { | |||
| 44 | + | c.requestTimeout = timeout | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // NewClient creates a bridge client with native WebKit and fetch transports. | |||
| 49 | + | func NewClient(native, fetch Transport, opts ...ClientOption) *Client { | |||
| 50 | + | client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout} | |||
| 51 | + | for _, opt := range opts { | |||
| 52 | + | if opt != nil { | |||
| 53 | + | opt(client) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return client | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // Send builds a WebUI message envelope and sends it through the active transport. | |||
| 60 | + | func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) { | |||
| 61 | + | if c == nil { | |||
| 62 | + | return nil, errors.New("bridge client is nil") | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | envelope, err := buildMessageEnvelope(msgType, payload) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return nil, err | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | transport := c.transport() | |||
| 71 | + | if transport == nil { | |||
| 72 | + | return nil, errors.New("no bridge transport available") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | return transport.Send(ctx, envelope) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |||
| 79 | + | if limit < 0 { | |||
| 80 | + | return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit) | |||
| 81 | + | } | |||
| 82 | + | if offset < 0 { | |||
| 83 | + | return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset) | |||
| 84 | + | } | |||
| 85 | + | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct { | |||
| 86 | + | RequestID string `json:"requestId"` | |||
| 87 | + | Limit int `json:"limit"` | |||
| 88 | + | Offset int `json:"offset"` | |||
| 89 | + | }{RequestID: nextRequestID(), Limit: limit, Offset: offset}) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |||
| 93 | + | domain = strings.TrimSpace(domain) | |||
| 94 | + | if domain == "" { | |||
| 95 | + | return nil, fmt.Errorf("timeline-by-domain domain must be non-empty") | |||
| 96 | + | } | |||
| 97 | + | if limit < 0 { | |||
| 98 | + | return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit) | |||
| 99 | + | } | |||
| 100 | + | if offset < 0 { | |||
| 101 | + | return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset) | |||
| 102 | + | } | |||
| 103 | + | return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct { | |||
| 104 | + | RequestID string `json:"requestId"` | |||
| 105 | + | Domain string `json:"domain"` | |||
| 106 | + | Limit int `json:"limit"` | |||
| 107 | + | Offset int `json:"offset"` | |||
| 108 | + | }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset}) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 112 | + | beforeCursor := "" | |||
| 113 | + | if !before.IsZero() { | |||
| 114 | + | beforeCursor = before.Format(time.RFC3339Nano) | |||
| 115 | + | } | |||
| 116 | + | return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct { | |||
| 117 | + | RequestID string `json:"requestId"` | |||
| 118 | + | Before string `json:"before,omitempty"` | |||
| 119 | + | Domain string `json:"domain,omitempty"` | |||
| 120 | + | }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)}) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |||
| 124 | + | if limit < 0 { | |||
| 125 | + | return nil, fmt.Errorf("search limit must be non-negative, got %d", limit) | |||
| 126 | + | } | |||
| 127 | + | return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct { | |||
| 128 | + | RequestID string `json:"requestId"` | |||
| 129 | + | Query string `json:"query"` | |||
| 130 | + | Limit int `json:"limit"` | |||
| 131 | + | }{RequestID: nextRequestID(), Query: query, Limit: limit}) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (c *Client) DeleteEntry(ctx context.Context, id int64) error { | |||
| 135 | + | if id <= 0 { | |||
| 136 | + | return fmt.Errorf("delete entry id must be positive, got %d", id) | |||
| 137 | + | } | |||
| 138 | + | _, err := request[struct{}](c, ctx, "history_delete_entry", struct { | |||
| 139 | + | RequestID string `json:"requestId"` | |||
| 140 | + | ID int64 `json:"id"` | |||
| 141 | + | }{RequestID: nextRequestID(), ID: id}) | |||
| 142 | + | return err | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (c *Client) DeleteRange(ctx context.Context, rangeID string) error { | |||
| 146 | + | rangeID = strings.TrimSpace(rangeID) | |||
| 147 | + | if rangeID == "" { | |||
| 148 | + | return errors.New("history range ID cannot be empty") | |||
| 149 | + | } | |||
| 150 | + | _, err := request[struct{}](c, ctx, "history_delete_range", struct { | |||
| 151 | + | RequestID string `json:"requestId"` | |||
| 152 | + | Range string `json:"range"` | |||
| 153 | + | }{RequestID: nextRequestID(), Range: rangeID}) | |||
| 154 | + | return err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // Current intentionally targets the direct HTTP config endpoint rather than a | |||
| 158 | + | // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config. | |||
| 159 | + | func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 160 | + | return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct { | |||
| 161 | + | RequestID string `json:"requestId"` | |||
| 162 | + | }{RequestID: nextRequestID()}) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Default intentionally targets the direct HTTP config endpoint rather than a | |||
| 166 | + | // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default. | |||
| 167 | + | func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 168 | + | return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct { | |||
| 169 | + | RequestID string `json:"requestId"` | |||
| 170 | + | }{RequestID: nextRequestID()}) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error { | |||
| 174 | + | _, err := request[struct{}](c, ctx, "save_config", cfg) | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) { | |||
| 179 | + | return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct { | |||
| 180 | + | RequestID string `json:"requestId"` | |||
| 181 | + | }{RequestID: nextRequestID()}) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) { | |||
| 185 | + | return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 189 | + | _, err := request[struct{}](c, ctx, "reset_keybinding", req) | |||
| 190 | + | return err | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (c *Client) ResetAllKeybindings(ctx context.Context) error { | |||
| 194 | + | _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct { | |||
| 195 | + | RequestID string `json:"requestId"` | |||
| 196 | + | }{RequestID: nextRequestID()}) | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) { | |||
| 201 | + | return request[*entity.HistoryStats](c, ctx, "history_stats", struct { | |||
| 202 | + | RequestID string `json:"requestId"` | |||
| 203 | + | }{RequestID: nextRequestID()}) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) { | |||
| 207 | + | return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct { | |||
| 208 | + | RequestID string `json:"requestId"` | |||
| 209 | + | }{RequestID: nextRequestID()}) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) { | |||
| 213 | + | if limit < 0 { | |||
| 214 | + | return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit) | |||
| 215 | + | } | |||
| 216 | + | return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct { | |||
| 217 | + | RequestID string `json:"requestId"` | |||
| 218 | + | Limit int `json:"limit"` | |||
| 219 | + | }{RequestID: nextRequestID(), Limit: limit}) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func (c *Client) DeleteDomain(ctx context.Context, domain string) error { | |||
| 223 | + | domain = strings.TrimSpace(domain) | |||
| 224 | + | if domain == "" { | |||
| 225 | + | return errors.New("history domain cannot be empty") | |||
| 226 | + | } | |||
| 227 | + | _, err := request[struct{}](c, ctx, "history_delete_domain", struct { | |||
| 228 | + | RequestID string `json:"requestId"` | |||
| 229 | + | Domain string `json:"domain"` | |||
| 230 | + | }{RequestID: nextRequestID(), Domain: domain}) | |||
| 231 | + | return err | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) { | |||
| 235 | + | return request[[]*entity.Favorite](c, ctx, "favorite_list", struct { | |||
| 236 | + | RequestID string `json:"requestId"` | |||
| 237 | + | }{RequestID: nextRequestID()}) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 241 | + | return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input)) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 245 | + | if input.ID <= 0 { | |||
| 246 | + | return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID) | |||
| 247 | + | } | |||
| 248 | + | return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input)) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (c *Client) DeleteFavorite(ctx context.Context, id int64) error { | |||
| 252 | + | if id <= 0 { | |||
| 253 | + | return fmt.Errorf("delete favorite id must be positive, got %d", id) | |||
| 254 | + | } | |||
| 255 | + | _, err := request[struct{}](c, ctx, "favorite_delete", struct { | |||
| 256 | + | RequestID string `json:"requestId"` | |||
| 257 | + | ID int64 `json:"id"` | |||
| 258 | + | }{RequestID: nextRequestID(), ID: id}) | |||
| 259 | + | return err | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func favoriteCreatePayload(input dto.FavoriteCreateInput) any { | |||
| 263 | + | return struct { | |||
| 264 | + | RequestID string `json:"requestId"` | |||
| 265 | + | URL string `json:"url"` | |||
| 266 | + | Title string `json:"title"` | |||
| 267 | + | FaviconURL string `json:"favicon_url"` | |||
| 268 | + | FolderID *int64 `json:"folder_id"` | |||
| 269 | + | Tags []int64 `json:"tags"` | |||
| 270 | + | }{ | |||
| 271 | + | RequestID: nextRequestID(), | |||
| 272 | + | URL: input.URL, | |||
| 273 | + | Title: input.Title, | |||
| 274 | + | FaviconURL: input.FaviconURL, | |||
| 275 | + | FolderID: folderIDPayload(input.FolderID), | |||
| 276 | + | Tags: tagIDPayloads(input.Tags), | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any { | |||
| 281 | + | return struct { | |||
| 282 | + | RequestID string `json:"requestId"` | |||
| 283 | + | ID int64 `json:"id"` | |||
| 284 | + | Title string `json:"title"` | |||
| 285 | + | FaviconURL string `json:"favicon_url"` | |||
| 286 | + | FolderID *int64 `json:"folder_id"` | |||
| 287 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 288 | + | }{ | |||
| 289 | + | RequestID: nextRequestID(), | |||
| 290 | + | ID: int64(input.ID), | |||
| 291 | + | Title: input.Title, | |||
| 292 | + | FaviconURL: input.FaviconURL, | |||
| 293 | + | FolderID: folderIDPayload(input.FolderID), | |||
| 294 | + | ShortcutKey: input.ShortcutKey, | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func folderIDPayload(id *entity.FolderID) *int64 { | |||
| 299 | + | if id == nil { | |||
| 300 | + | return nil | |||
| 301 | + | } | |||
| 302 | + | value := int64(*id) | |||
| 303 | + | return &value | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func tagIDPayloads(ids []entity.TagID) []int64 { | |||
| 307 | + | out := make([]int64, 0, len(ids)) | |||
| 308 | + | for _, id := range ids { | |||
| 309 | + | out = append(out, int64(id)) | |||
| 310 | + | } | |||
| 311 | + | return out | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) { | |||
| 315 | + | return request[[]*entity.Folder](c, ctx, "folder_list", struct { | |||
| 316 | + | RequestID string `json:"requestId"` | |||
| 317 | + | }{RequestID: nextRequestID()}) | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) { | |||
| 321 | + | return request[[]*entity.Tag](c, ctx, "tag_list", struct { | |||
| 322 | + | RequestID string `json:"requestId"` | |||
| 323 | + | }{RequestID: nextRequestID()}) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error { | |||
| 327 | + | if favoriteID <= 0 { | |||
| 328 | + | return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID) | |||
| 329 | + | } | |||
| 330 | + | _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct { | |||
| 331 | + | RequestID string `json:"requestId"` | |||
| 332 | + | FavoriteID int64 `json:"favorite_id"` | |||
| 333 | + | ShortcutKey *int `json:"shortcut_key"` | |||
| 334 | + | }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey}) | |||
| 335 | + | return err | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error { | |||
| 339 | + | if favoriteID <= 0 { | |||
| 340 | + | return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID) | |||
| 341 | + | } | |||
| 342 | + | _, err := request[struct{}](c, ctx, "favorite_set_folder", struct { | |||
| 343 | + | RequestID string `json:"requestId"` | |||
| 344 | + | FavoriteID int64 `json:"favorite_id"` | |||
| 345 | + | FolderID *int64 `json:"folder_id"` | |||
| 346 | + | }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID}) | |||
| 347 | + | return err | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) { | |||
| 351 | + | name = strings.TrimSpace(name) | |||
| 352 | + | if name == "" { | |||
| 353 | + | return nil, fmt.Errorf("folder name is required") | |||
| 354 | + | } | |||
| 355 | + | var iconPtr *string | |||
| 356 | + | if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" { | |||
| 357 | + | iconPtr = &trimmedIcon | |||
| 358 | + | } | |||
| 359 | + | return request[*entity.Folder](c, ctx, "folder_create", struct { | |||
| 360 | + | RequestID string `json:"requestId"` | |||
| 361 | + | Name string `json:"name"` | |||
| 362 | + | Icon *string `json:"icon"` | |||
| 363 | + | ParentID *int64 `json:"parent_id,omitempty"` | |||
| 364 | + | }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID}) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | |||
| 368 | + | if id <= 0 { | |||
| 369 | + | return fmt.Errorf("update folder id must be positive, got %d", id) | |||
| 370 | + | } | |||
| 371 | + | name = strings.TrimSpace(name) | |||
| 372 | + | if name == "" { | |||
| 373 | + | return fmt.Errorf("folder name is required") | |||
| 374 | + | } | |||
| 375 | + | var iconPtr *string | |||
| 376 | + | if trimmed := strings.TrimSpace(icon); trimmed != "" { | |||
| 377 | + | iconPtr = &trimmed | |||
| 378 | + | } | |||
| 379 | + | _, err := request[struct{}](c, ctx, "folder_update", struct { | |||
| 380 | + | RequestID string `json:"requestId"` | |||
| 381 | + | ID int64 `json:"id"` | |||
| 382 | + | Name string `json:"name"` | |||
| 383 | + | Icon *string `json:"icon"` | |||
| 384 | + | }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr}) | |||
| 385 | + | return err | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func (c *Client) DeleteFolder(ctx context.Context, id int64) error { | |||
| 389 | + | if id <= 0 { | |||
| 390 | + | return fmt.Errorf("delete folder id must be positive, got %d", id) | |||
| 391 | + | } | |||
| 392 | + | _, err := request[struct{}](c, ctx, "folder_delete", struct { | |||
| 393 | + | RequestID string `json:"requestId"` | |||
| 394 | + | ID int64 `json:"id"` | |||
| 395 | + | }{RequestID: nextRequestID(), ID: id}) | |||
| 396 | + | return err | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) { | |||
| 400 | + | name = strings.TrimSpace(name) | |||
| 401 | + | if name == "" { | |||
| 402 | + | return nil, fmt.Errorf("tag name is required") | |||
| 403 | + | } | |||
| 404 | + | var colorPtr *string | |||
| 405 | + | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | |||
| 406 | + | colorPtr = &trimmedColor | |||
| 407 | + | } | |||
| 408 | + | return request[*entity.Tag](c, ctx, "tag_create", struct { | |||
| 409 | + | RequestID string `json:"requestId"` | |||
| 410 | + | Name string `json:"name"` | |||
| 411 | + | Color *string `json:"color"` | |||
| 412 | + | }{RequestID: nextRequestID(), Name: name, Color: colorPtr}) | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error { | |||
| 416 | + | if id <= 0 { | |||
| 417 | + | return fmt.Errorf("update tag id must be positive, got %d", id) | |||
| 418 | + | } | |||
| 419 | + | trimmedName := strings.TrimSpace(name) | |||
| 420 | + | if trimmedName == "" { | |||
| 421 | + | return fmt.Errorf("tag name is required") | |||
| 422 | + | } | |||
| 423 | + | namePtr := &trimmedName | |||
| 424 | + | var colorPtr *string | |||
| 425 | + | if trimmedColor := strings.TrimSpace(color); trimmedColor != "" { | |||
| 426 | + | colorPtr = &trimmedColor | |||
| 427 | + | } | |||
| 428 | + | _, err := request[struct{}](c, ctx, "tag_update", struct { | |||
| 429 | + | RequestID string `json:"requestId"` | |||
| 430 | + | ID int64 `json:"id"` | |||
| 431 | + | Name *string `json:"name"` | |||
| 432 | + | Color *string `json:"color"` | |||
| 433 | + | }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr}) | |||
| 434 | + | return err | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func (c *Client) DeleteTag(ctx context.Context, id int64) error { | |||
| 438 | + | if id <= 0 { | |||
| 439 | + | return fmt.Errorf("delete tag id must be positive, got %d", id) | |||
| 440 | + | } | |||
| 441 | + | _, err := request[struct{}](c, ctx, "tag_delete", struct { | |||
| 442 | + | RequestID string `json:"requestId"` | |||
| 443 | + | ID int64 `json:"id"` | |||
| 444 | + | }{RequestID: nextRequestID(), ID: id}) | |||
| 445 | + | return err | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error { | |||
| 449 | + | if favoriteID <= 0 { | |||
| 450 | + | return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID) | |||
| 451 | + | } | |||
| 452 | + | if tagID <= 0 { | |||
| 453 | + | return fmt.Errorf("assign tag id must be positive, got %d", tagID) | |||
| 454 | + | } | |||
| 455 | + | _, err := request[struct{}](c, ctx, "tag_assign", struct { | |||
| 456 | + | RequestID string `json:"requestId"` | |||
| 457 | + | FavoriteID int64 `json:"favorite_id"` | |||
| 458 | + | TagID int64 `json:"tag_id"` | |||
| 459 | + | }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID}) | |||
| 460 | + | return err | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error { | |||
| 464 | + | if favoriteID <= 0 { | |||
| 465 | + | return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID) | |||
| 466 | + | } | |||
| 467 | + | if tagID <= 0 { | |||
| 468 | + | return fmt.Errorf("remove tag id must be positive, got %d", tagID) | |||
| 469 | + | } | |||
| 470 | + | _, err := request[struct{}](c, ctx, "tag_remove", struct { | |||
| 471 | + | RequestID string `json:"requestId"` | |||
| 472 | + | FavoriteID int64 `json:"favorite_id"` | |||
| 473 | + | TagID int64 `json:"tag_id"` | |||
| 474 | + | }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID}) | |||
| 475 | + | return err | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func (c *Client) transport() Transport { | |||
| 479 | + | if c == nil { | |||
| 480 | + | return nil | |||
| 481 | + | } | |||
| 482 | + | if c.native != nil && c.native.Available() { | |||
| 483 | + | return c.native | |||
| 484 | + | } | |||
| 485 | + | if c.fetch != nil && c.fetch.Available() { | |||
| 486 | + | return c.fetch | |||
| 487 | + | } | |||
| 488 | + | return nil | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) { | |||
| 492 | + | var zero T | |||
| 493 | + | ||||
| 494 | + | ctx, cancel := c.withBridgeRequestTimeout(ctx) | |||
| 495 | + | defer cancel() | |||
| 496 | + | ||||
| 497 | + | raw, err := c.Send(ctx, msgType, payload) | |||
| 498 | + | if err != nil { | |||
| 499 | + | return zero, err | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | return decodeBridgeResponse[T](raw) | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | // withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil. | |||
| 506 | + | func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) { | |||
| 507 | + | timeout := defaultBridgeRequestTimeout | |||
| 508 | + | if c != nil { | |||
| 509 | + | timeout = c.requestTimeout | |||
| 510 | + | } | |||
| 511 | + | if _, ok := ctx.Deadline(); ok || timeout <= 0 { | |||
| 512 | + | return context.WithCancel(ctx) | |||
| 513 | + | } | |||
| 514 | + | return context.WithTimeout(ctx, timeout) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | type bridgeResponse struct { | |||
| 518 | + | RequestID string `json:"requestId"` | |||
| 519 | + | Success bool `json:"success"` | |||
| 520 | + | Data json.RawMessage `json:"data,omitempty"` | |||
| 521 | + | Error string `json:"error,omitempty"` | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func decodeBridgeResponse[T any](body []byte) (T, error) { | |||
| 525 | + | var result T | |||
| 526 | + | ||||
| 527 | + | var resp bridgeResponse | |||
| 528 | + | if err := json.Unmarshal(body, &resp); err != nil { | |||
| 529 | + | return result, fmt.Errorf("unmarshal bridge response: %w", err) | |||
| 530 | + | } | |||
| 531 | + | if !resp.Success { | |||
| 532 | + | if resp.Error == "" { | |||
| 533 | + | return result, errors.New("bridge request failed") | |||
| 534 | + | } | |||
| 535 | + | return result, errors.New(resp.Error) | |||
| 536 | + | } | |||
| 537 | + | if len(resp.Data) == 0 { | |||
| 538 | + | return result, nil | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | if err := json.Unmarshal(resp.Data, &result); err != nil { | |||
| 542 | + | return result, fmt.Errorf("unmarshal bridge data: %w", err) | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | return result, nil | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func nextRequestID() string { | |||
| 549 | + | return fmt.Sprintf("systemviews-%d", requestSeq.Add(1)) | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func buildMessageEnvelope(msgType string, payload any) ([]byte, error) { | |||
| 553 | + | if msgType == "" { | |||
| 554 | + | return nil, errors.New("message type cannot be empty") | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | body, err := json.Marshal(payload) | |||
| 558 | + | if err != nil { | |||
| 559 | + | return nil, fmt.Errorf("marshal payload: %w", err) | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | envelope := port.WebUIMessage{ | |||
| 563 | + | Type: msgType, | |||
| 564 | + | Payload: body, | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | data, err := json.Marshal(envelope) | |||
| 568 | + | if err != nil { | |||
| 569 | + | return nil, fmt.Errorf("marshal envelope: %w", err) | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | return data, nil | |||
| 573 | + | } | |||
A internal/infrastructure/systemviewsbridge/client_test.go +702 -0
| @@ -0,0 +1,702 @@ | |||||
| 1 | + | package systemviewsbridge | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestBuildMessageEnvelope(t *testing.T) { | |||
| 15 | + | t.Parallel() | |||
| 16 | + | ||||
| 17 | + | payload := struct { | |||
| 18 | + | RequestID string `json:"requestId"` | |||
| 19 | + | Limit int `json:"limit"` | |||
| 20 | + | }{RequestID: "req-1", Limit: 25} | |||
| 21 | + | ||||
| 22 | + | got, err := buildMessageEnvelope("history_timeline", payload) | |||
| 23 | + | if err != nil { | |||
| 24 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var msg port.WebUIMessage | |||
| 28 | + | if err := json.Unmarshal(got, &msg); err != nil { | |||
| 29 | + | t.Fatalf("unmarshal envelope: %v", err) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | if msg.Type != "history_timeline" { | |||
| 33 | + | t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` { | |||
| 37 | + | t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestClientSendPrefersNativeTransport(t *testing.T) { | |||
| 42 | + | t.Parallel() | |||
| 43 | + | ||||
| 44 | + | native := &fakeTransport{available: true, response: []byte(`{"transport":"native"}`)} | |||
| 45 | + | fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)} | |||
| 46 | + | ||||
| 47 | + | client := NewClient(native, fetch) | |||
| 48 | + | got, err := client.Send(context.Background(), "favorite_list", struct { | |||
| 49 | + | RequestID string `json:"requestId"` | |||
| 50 | + | }{RequestID: "req-2"}) | |||
| 51 | + | if err != nil { | |||
| 52 | + | t.Fatalf("Send() error = %v", err) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | if string(got) != `{"transport":"native"}` { | |||
| 56 | + | t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | if !native.called { | |||
| 60 | + | t.Fatal("native transport was not used") | |||
| 61 | + | } | |||
| 62 | + | if fetch.called { | |||
| 63 | + | t.Fatal("fetch transport was used unexpectedly") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | var msg port.WebUIMessage | |||
| 67 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 68 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 69 | + | } | |||
| 70 | + | if msg.Type != "favorite_list" { | |||
| 71 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list") | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestClientSendFallsBackToFetchTransport(t *testing.T) { | |||
| 76 | + | t.Parallel() | |||
| 77 | + | ||||
| 78 | + | native := &fakeTransport{available: false, response: []byte(`{"transport":"native"}`)} | |||
| 79 | + | fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)} | |||
| 80 | + | ||||
| 81 | + | client := NewClient(native, fetch) | |||
| 82 | + | got, err := client.Send(context.Background(), "favorite_list", struct { | |||
| 83 | + | RequestID string `json:"requestId"` | |||
| 84 | + | }{RequestID: "req-3"}) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatalf("Send() error = %v", err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | if string(got) != `{"transport":"fetch"}` { | |||
| 90 | + | t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | if native.called { | |||
| 94 | + | t.Fatal("native transport was used unexpectedly") | |||
| 95 | + | } | |||
| 96 | + | if !fetch.called { | |||
| 97 | + | t.Fatal("fetch transport was not used") | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestClientTimelineDecodesEntries(t *testing.T) { | |||
| 102 | + | t.Parallel() | |||
| 103 | + | ||||
| 104 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)} | |||
| 105 | + | client := NewClient(native, nil) | |||
| 106 | + | ||||
| 107 | + | entries, err := client.Timeline(context.Background(), 25, 0) | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatalf("Timeline() error = %v", err) | |||
| 110 | + | } | |||
| 111 | + | if len(entries) != 1 { | |||
| 112 | + | t.Fatalf("Timeline() len = %d, want 1", len(entries)) | |||
| 113 | + | } | |||
| 114 | + | if entries[0].URL != "https://example.com" || entries[0].Title != "Example" { | |||
| 115 | + | t.Fatalf("Timeline() entry = %+v", entries[0]) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | var msg port.WebUIMessage | |||
| 119 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 120 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 121 | + | } | |||
| 122 | + | if msg.Type != "history_timeline" { | |||
| 123 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline") | |||
| 124 | + | } | |||
| 125 | + | if gotPayload := string(msg.Payload); gotPayload == "" { | |||
| 126 | + | t.Fatal("sent payload was empty") | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) { | |||
| 131 | + | t.Parallel() | |||
| 132 | + | ||||
| 133 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-window","success":true}`)} | |||
| 134 | + | client := NewClient(native, nil) | |||
| 135 | + | ||||
| 136 | + | _, err := client.TimelineWindow(context.Background(), time.Time{}, "") | |||
| 137 | + | if err != nil { | |||
| 138 | + | t.Fatalf("TimelineWindow() error = %v", err) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | var msg port.WebUIMessage | |||
| 142 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 143 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 144 | + | } | |||
| 145 | + | if msg.Type != "history_timeline_window" { | |||
| 146 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window") | |||
| 147 | + | } | |||
| 148 | + | var payload map[string]any | |||
| 149 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 150 | + | t.Fatalf("unmarshal payload: %v", err) | |||
| 151 | + | } | |||
| 152 | + | if _, ok := payload["before"]; ok { | |||
| 153 | + | t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) { | |||
| 158 | + | t.Parallel() | |||
| 159 | + | ||||
| 160 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)} | |||
| 161 | + | client := NewClient(transport, nil) | |||
| 162 | + | ||||
| 163 | + | err := client.DeleteRange(context.Background(), " ") | |||
| 164 | + | if err == nil { | |||
| 165 | + | t.Fatal("DeleteRange() error = nil, want validation error") | |||
| 166 | + | } | |||
| 167 | + | if transport.called { | |||
| 168 | + | t.Fatal("DeleteRange() sent a request for an invalid range") | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestClientDeleteRangeSendsRange(t *testing.T) { | |||
| 173 | + | t.Parallel() | |||
| 174 | + | ||||
| 175 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-10","success":true}`)} | |||
| 176 | + | client := NewClient(native, nil) | |||
| 177 | + | ||||
| 178 | + | if err := client.DeleteRange(context.Background(), "week"); err != nil { | |||
| 179 | + | t.Fatalf("DeleteRange() error = %v", err) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | var msg port.WebUIMessage | |||
| 183 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 184 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 185 | + | } | |||
| 186 | + | if msg.Type != "history_delete_range" { | |||
| 187 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range") | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestClientListDecodesFavorites(t *testing.T) { | |||
| 192 | + | t.Parallel() | |||
| 193 | + | ||||
| 194 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)} | |||
| 195 | + | client := NewClient(native, nil) | |||
| 196 | + | ||||
| 197 | + | favorites, err := client.List(context.Background()) | |||
| 198 | + | if err != nil { | |||
| 199 | + | t.Fatalf("List() error = %v", err) | |||
| 200 | + | } | |||
| 201 | + | if len(favorites) != 1 { | |||
| 202 | + | t.Fatalf("List() len = %d, want 1", len(favorites)) | |||
| 203 | + | } | |||
| 204 | + | if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" { | |||
| 205 | + | t.Fatalf("List() favorite = %+v", favorites[0]) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | var msg port.WebUIMessage | |||
| 209 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 210 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 211 | + | } | |||
| 212 | + | if msg.Type != "favorite_list" { | |||
| 213 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list") | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestClientListFoldersDecodesFolders(t *testing.T) { | |||
| 218 | + | t.Parallel() | |||
| 219 | + | ||||
| 220 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)} | |||
| 221 | + | client := NewClient(native, nil) | |||
| 222 | + | ||||
| 223 | + | folders, err := client.ListFolders(context.Background()) | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatalf("ListFolders() error = %v", err) | |||
| 226 | + | } | |||
| 227 | + | if len(folders) != 1 { | |||
| 228 | + | t.Fatalf("ListFolders() len = %d, want 1", len(folders)) | |||
| 229 | + | } | |||
| 230 | + | if folders[0].Name != "Read Later" { | |||
| 231 | + | t.Fatalf("ListFolders() folder = %+v", folders[0]) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | var msg port.WebUIMessage | |||
| 235 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 236 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 237 | + | } | |||
| 238 | + | if msg.Type != "folder_list" { | |||
| 239 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list") | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestClientListTagsDecodesTags(t *testing.T) { | |||
| 244 | + | t.Parallel() | |||
| 245 | + | ||||
| 246 | + | native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`)} | |||
| 247 | + | client := NewClient(native, nil) | |||
| 248 | + | ||||
| 249 | + | tags, err := client.ListTags(context.Background()) | |||
| 250 | + | if err != nil { | |||
| 251 | + | t.Fatalf("ListTags() error = %v", err) | |||
| 252 | + | } | |||
| 253 | + | if len(tags) != 1 { | |||
| 254 | + | t.Fatalf("ListTags() len = %d, want 1", len(tags)) | |||
| 255 | + | } | |||
| 256 | + | if tags[0].Name != "Go" || tags[0].Color != "#00ff00" { | |||
| 257 | + | t.Fatalf("ListTags() tag = %+v", tags[0]) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | var msg port.WebUIMessage | |||
| 261 | + | if err := json.Unmarshal(native.last, &msg); err != nil { | |||
| 262 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 263 | + | } | |||
| 264 | + | if msg.Type != "tag_list" { | |||
| 265 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list") | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) { | |||
| 270 | + | t.Parallel() | |||
| 271 | + | ||||
| 272 | + | resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`) | |||
| 273 | + | ||||
| 274 | + | currentClient := NewClient(&fakeTransport{available: true, response: resp}, nil) | |||
| 275 | + | current, err := currentClient.Current(context.Background()) | |||
| 276 | + | if err != nil { | |||
| 277 | + | t.Fatalf("Current() error = %v", err) | |||
| 278 | + | } | |||
| 279 | + | if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" { | |||
| 280 | + | t.Fatalf("Current() = %+v", current) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | var msg port.WebUIMessage | |||
| 284 | + | if unmarshalErr := json.Unmarshal(currentClient.native.(*fakeTransport).last, &msg); unmarshalErr != nil { | |||
| 285 | + | t.Fatalf("unmarshal sent envelope: %v", unmarshalErr) | |||
| 286 | + | } | |||
| 287 | + | if msg.Type != "/api/config" { | |||
| 288 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config") | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | defaultClient := NewClient(&fakeTransport{available: true, response: resp}, nil) | |||
| 292 | + | defaultCfg, err := defaultClient.Default(context.Background()) | |||
| 293 | + | if err != nil { | |||
| 294 | + | t.Fatalf("Default() error = %v", err) | |||
| 295 | + | } | |||
| 296 | + | if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" { | |||
| 297 | + | t.Fatalf("Default() = %+v", defaultCfg) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | if err := json.Unmarshal(defaultClient.native.(*fakeTransport).last, &msg); err != nil { | |||
| 301 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 302 | + | } | |||
| 303 | + | if msg.Type != "/api/config/default" { | |||
| 304 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default") | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 309 | + | t.Parallel() | |||
| 310 | + | ||||
| 311 | + | tests := []struct { | |||
| 312 | + | name string | |||
| 313 | + | call func(*testing.T, *Client) error | |||
| 314 | + | want string | |||
| 315 | + | payload any | |||
| 316 | + | }{ | |||
| 317 | + | { | |||
| 318 | + | name: "save config", | |||
| 319 | + | call: func(_ *testing.T, client *Client) error { | |||
| 320 | + | return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"}) | |||
| 321 | + | }, | |||
| 322 | + | want: "save_config", | |||
| 323 | + | payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"}, | |||
| 324 | + | }, | |||
| 325 | + | { | |||
| 326 | + | name: "get keybindings", | |||
| 327 | + | call: func(t *testing.T, client *Client) error { | |||
| 328 | + | got, err := client.GetKeybindings(context.Background()) | |||
| 329 | + | if err != nil { | |||
| 330 | + | return err | |||
| 331 | + | } | |||
| 332 | + | if len(got.Groups) != 1 || got.Groups[0].Mode != "default" { | |||
| 333 | + | t.Fatalf("GetKeybindings() = %#v", got) | |||
| 334 | + | } | |||
| 335 | + | return nil | |||
| 336 | + | }, | |||
| 337 | + | want: "get_keybindings", | |||
| 338 | + | }, | |||
| 339 | + | { | |||
| 340 | + | name: "set keybinding", | |||
| 341 | + | call: func(t *testing.T, client *Client) error { | |||
| 342 | + | got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}}) | |||
| 343 | + | if err != nil { | |||
| 344 | + | return err | |||
| 345 | + | } | |||
| 346 | + | if len(got.Conflicts) != 0 { | |||
| 347 | + | t.Fatalf("SetKeybinding() = %#v", got) | |||
| 348 | + | } | |||
| 349 | + | return nil | |||
| 350 | + | }, | |||
| 351 | + | want: "set_keybinding", | |||
| 352 | + | payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}}, | |||
| 353 | + | }, | |||
| 354 | + | { | |||
| 355 | + | name: "reset keybinding", | |||
| 356 | + | call: func(_ *testing.T, client *Client) error { | |||
| 357 | + | return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"}) | |||
| 358 | + | }, | |||
| 359 | + | want: "reset_keybinding", | |||
| 360 | + | payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"}, | |||
| 361 | + | }, | |||
| 362 | + | { | |||
| 363 | + | name: "reset all keybindings", | |||
| 364 | + | call: func(_ *testing.T, client *Client) error { | |||
| 365 | + | return client.ResetAllKeybindings(context.Background()) | |||
| 366 | + | }, | |||
| 367 | + | want: "reset_all_keybindings", | |||
| 368 | + | }, | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | for _, tt := range tests { | |||
| 372 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 373 | + | t.Parallel() | |||
| 374 | + | ||||
| 375 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`)} | |||
| 376 | + | if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" { | |||
| 377 | + | transport.response = []byte(`{"requestId":"req-15","success":true}`) | |||
| 378 | + | } | |||
| 379 | + | if tt.name == "get keybindings" { | |||
| 380 | + | transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`) | |||
| 381 | + | } | |||
| 382 | + | client := NewClient(transport, nil) | |||
| 383 | + | ||||
| 384 | + | if err := tt.call(t, client); err != nil { | |||
| 385 | + | t.Fatalf("call() error = %v", err) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | var msg port.WebUIMessage | |||
| 389 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 390 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 391 | + | } | |||
| 392 | + | if msg.Type != tt.want { | |||
| 393 | + | t.Fatalf("sent type = %q, want %q", msg.Type, tt.want) | |||
| 394 | + | } | |||
| 395 | + | if tt.payload != nil && len(msg.Payload) == 0 { | |||
| 396 | + | t.Fatal("sent payload was empty") | |||
| 397 | + | } | |||
| 398 | + | }) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) { | |||
| 403 | + | t.Parallel() | |||
| 404 | + | ||||
| 405 | + | transport := &blockingTransport{available: true} | |||
| 406 | + | client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond)) | |||
| 407 | + | ||||
| 408 | + | _, err := client.Timeline(context.Background(), 25, 0) | |||
| 409 | + | if !errors.Is(err, context.DeadlineExceeded) { | |||
| 410 | + | t.Fatalf("Timeline() error = %v, want context deadline exceeded", err) | |||
| 411 | + | } | |||
| 412 | + | if !transport.called { | |||
| 413 | + | t.Fatal("transport was not called") | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||
| 418 | + | t.Parallel() | |||
| 419 | + | ||||
| 420 | + | parentID := int64(42) | |||
| 421 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)} | |||
| 422 | + | client := NewClient(transport, nil) | |||
| 423 | + | ||||
| 424 | + | folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID) | |||
| 425 | + | if err != nil { | |||
| 426 | + | t.Fatalf("CreateFolder() error = %v", err) | |||
| 427 | + | } | |||
| 428 | + | if folder == nil { | |||
| 429 | + | t.Fatal("CreateFolder() returned nil folder") | |||
| 430 | + | } | |||
| 431 | + | if folder.Name != "Nested" { | |||
| 432 | + | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | |||
| 433 | + | } | |||
| 434 | + | if folder.Icon != "📁" { | |||
| 435 | + | t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon) | |||
| 436 | + | } | |||
| 437 | + | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | |||
| 438 | + | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | var msg port.WebUIMessage | |||
| 442 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 443 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 444 | + | } | |||
| 445 | + | if msg.Type != "folder_create" { | |||
| 446 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create") | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | var payload struct { | |||
| 450 | + | RequestID string `json:"requestId"` | |||
| 451 | + | Name string `json:"name"` | |||
| 452 | + | Icon *string `json:"icon"` | |||
| 453 | + | ParentID *int64 `json:"parent_id"` | |||
| 454 | + | } | |||
| 455 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 456 | + | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 457 | + | } | |||
| 458 | + | if payload.ParentID == nil || *payload.ParentID != parentID { | |||
| 459 | + | t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID) | |||
| 460 | + | } | |||
| 461 | + | if payload.Name != "Nested" { | |||
| 462 | + | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | |||
| 463 | + | } | |||
| 464 | + | if payload.Icon == nil || *payload.Icon != "📁" { | |||
| 465 | + | t.Fatalf("sent icon = %#v, want 📁", payload.Icon) | |||
| 466 | + | } | |||
| 467 | + | if payload.RequestID == "" { | |||
| 468 | + | t.Fatal("sent requestId was empty") | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) { | |||
| 473 | + | t.Parallel() | |||
| 474 | + | ||||
| 475 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)} | |||
| 476 | + | client := NewClient(transport, nil) | |||
| 477 | + | ||||
| 478 | + | _, err := client.DomainStats(context.Background(), -1) | |||
| 479 | + | if err == nil { | |||
| 480 | + | t.Fatal("DomainStats() error = nil, want validation error") | |||
| 481 | + | } | |||
| 482 | + | if transport.called { | |||
| 483 | + | t.Fatal("DomainStats() sent a request for a negative limit") | |||
| 484 | + | } | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) { | |||
| 488 | + | t.Parallel() | |||
| 489 | + | ||||
| 490 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)} | |||
| 491 | + | client := NewClient(transport, nil) | |||
| 492 | + | ||||
| 493 | + | err := client.DeleteDomain(context.Background(), " ") | |||
| 494 | + | if err == nil { | |||
| 495 | + | t.Fatal("DeleteDomain() error = nil, want validation error") | |||
| 496 | + | } | |||
| 497 | + | if transport.called { | |||
| 498 | + | t.Fatal("DeleteDomain() sent a request for an empty domain") | |||
| 499 | + | } | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func TestClientUpdateFolderTrimsIcon(t *testing.T) { | |||
| 503 | + | t.Parallel() | |||
| 504 | + | ||||
| 505 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","success":true}`)} | |||
| 506 | + | client := NewClient(transport, nil) | |||
| 507 | + | ||||
| 508 | + | if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil { | |||
| 509 | + | t.Fatalf("UpdateFolder() error = %v", err) | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | var msg port.WebUIMessage | |||
| 513 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 514 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 515 | + | } | |||
| 516 | + | if msg.Type != "folder_update" { | |||
| 517 | + | t.Fatalf("sent type = %q, want folder_update", msg.Type) | |||
| 518 | + | } | |||
| 519 | + | var payload struct { | |||
| 520 | + | Icon *string `json:"icon"` | |||
| 521 | + | } | |||
| 522 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 523 | + | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 524 | + | } | |||
| 525 | + | if payload.Icon == nil || *payload.Icon != "📚" { | |||
| 526 | + | t.Fatalf("sent icon = %#v, want 📚", payload.Icon) | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) { | |||
| 531 | + | t.Parallel() | |||
| 532 | + | ||||
| 533 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)} | |||
| 534 | + | client := NewClient(transport, nil) | |||
| 535 | + | ||||
| 536 | + | if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil { | |||
| 537 | + | t.Fatalf("UpdateFolder() error = %v", err) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | var msg port.WebUIMessage | |||
| 541 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 542 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 543 | + | } | |||
| 544 | + | var payload struct { | |||
| 545 | + | Icon *string `json:"icon"` | |||
| 546 | + | } | |||
| 547 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 548 | + | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 549 | + | } | |||
| 550 | + | if payload.Icon != nil { | |||
| 551 | + | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |||
| 552 | + | } | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func TestClientDeleteEntryRejectsInvalidID(t *testing.T) { | |||
| 556 | + | t.Parallel() | |||
| 557 | + | ||||
| 558 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)} | |||
| 559 | + | client := NewClient(transport, nil) | |||
| 560 | + | ||||
| 561 | + | err := client.DeleteEntry(context.Background(), 0) | |||
| 562 | + | if err == nil { | |||
| 563 | + | t.Fatal("DeleteEntry() error = nil, want validation error") | |||
| 564 | + | } | |||
| 565 | + | if transport.called { | |||
| 566 | + | t.Fatal("DeleteEntry() sent a request for an invalid id") | |||
| 567 | + | } | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | func TestClientTagActionsTrimFields(t *testing.T) { | |||
| 571 | + | t.Parallel() | |||
| 572 | + | ||||
| 573 | + | createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)} | |||
| 574 | + | client := NewClient(createTransport, nil) | |||
| 575 | + | if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil { | |||
| 576 | + | t.Fatalf("CreateTag() error = %v", err) | |||
| 577 | + | } | |||
| 578 | + | var msg port.WebUIMessage | |||
| 579 | + | if err := json.Unmarshal(createTransport.last, &msg); err != nil { | |||
| 580 | + | t.Fatalf("unmarshal create envelope: %v", err) | |||
| 581 | + | } | |||
| 582 | + | var createPayload struct { | |||
| 583 | + | Name string `json:"name"` | |||
| 584 | + | Color *string `json:"color"` | |||
| 585 | + | } | |||
| 586 | + | if err := json.Unmarshal(msg.Payload, &createPayload); err != nil { | |||
| 587 | + | t.Fatalf("unmarshal create payload: %v", err) | |||
| 588 | + | } | |||
| 589 | + | if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" { | |||
| 590 | + | t.Fatalf("CreateTag payload = %#v", createPayload) | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)} | |||
| 594 | + | client = NewClient(updateTransport, nil) | |||
| 595 | + | if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil { | |||
| 596 | + | t.Fatalf("UpdateTag() error = %v", err) | |||
| 597 | + | } | |||
| 598 | + | if err := json.Unmarshal(updateTransport.last, &msg); err != nil { | |||
| 599 | + | t.Fatalf("unmarshal update envelope: %v", err) | |||
| 600 | + | } | |||
| 601 | + | var updatePayload struct { | |||
| 602 | + | Name *string `json:"name"` | |||
| 603 | + | Color *string `json:"color"` | |||
| 604 | + | } | |||
| 605 | + | if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil { | |||
| 606 | + | t.Fatalf("unmarshal update payload: %v", err) | |||
| 607 | + | } | |||
| 608 | + | if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil { | |||
| 609 | + | t.Fatalf("UpdateTag payload = %#v", updatePayload) | |||
| 610 | + | } | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | type fakeTransport struct { | |||
| 614 | + | available bool | |||
| 615 | + | called bool | |||
| 616 | + | last []byte | |||
| 617 | + | response []byte | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func (t *fakeTransport) Available() bool { return t.available } | |||
| 621 | + | ||||
| 622 | + | func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) { | |||
| 623 | + | t.called = true | |||
| 624 | + | t.last = append(t.last[:0], body...) | |||
| 625 | + | return t.response, nil | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | type blockingTransport struct { | |||
| 629 | + | available bool | |||
| 630 | + | called bool | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | func (t *blockingTransport) Available() bool { return t.available } | |||
| 634 | + | ||||
| 635 | + | func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) { | |||
| 636 | + | t.called = true | |||
| 637 | + | <-ctx.Done() | |||
| 638 | + | return nil, ctx.Err() | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) { | |||
| 642 | + | t.Parallel() | |||
| 643 | + | ||||
| 644 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)} | |||
| 645 | + | client := NewClient(transport, nil) | |||
| 646 | + | ||||
| 647 | + | checks := []struct { | |||
| 648 | + | name string | |||
| 649 | + | call func() error | |||
| 650 | + | }{ | |||
| 651 | + | {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }}, | |||
| 652 | + | {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }}, | |||
| 653 | + | {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }}, | |||
| 654 | + | {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }}, | |||
| 655 | + | {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }}, | |||
| 656 | + | {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }}, | |||
| 657 | + | {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }}, | |||
| 658 | + | {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }}, | |||
| 659 | + | {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }}, | |||
| 660 | + | {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }}, | |||
| 661 | + | {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }}, | |||
| 662 | + | {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }}, | |||
| 663 | + | {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }}, | |||
| 664 | + | {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }}, | |||
| 665 | + | {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }}, | |||
| 666 | + | } | |||
| 667 | + | for _, check := range checks { | |||
| 668 | + | t.Run(check.name, func(t *testing.T) { | |||
| 669 | + | if err := check.call(); err == nil { | |||
| 670 | + | t.Fatal("error = nil, want validation error") | |||
| 671 | + | } | |||
| 672 | + | }) | |||
| 673 | + | } | |||
| 674 | + | if transport.called { | |||
| 675 | + | t.Fatal("transport called for invalid favorite mutation IDs") | |||
| 676 | + | } | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | func TestClientRejectsNegativeHistoryLimits(t *testing.T) { | |||
| 680 | + | t.Parallel() | |||
| 681 | + | ||||
| 682 | + | client := NewClient(&fakeTransport{available: true}, nil) | |||
| 683 | + | ||||
| 684 | + | if _, err := client.Timeline(context.Background(), -1, 0); err == nil { | |||
| 685 | + | t.Fatal("Timeline() error = nil, want error") | |||
| 686 | + | } | |||
| 687 | + | if _, err := client.Timeline(context.Background(), 1, -1); err == nil { | |||
| 688 | + | t.Fatal("Timeline() error = nil, want error") | |||
| 689 | + | } | |||
| 690 | + | if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil { | |||
| 691 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |||
| 692 | + | } | |||
| 693 | + | if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil { | |||
| 694 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |||
| 695 | + | } | |||
| 696 | + | if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil { | |||
| 697 | + | t.Fatal("TimelineByDomain() error = nil, want error") | |||
| 698 | + | } | |||
| 699 | + | if _, err := client.Search(context.Background(), "example", -1); err == nil { | |||
| 700 | + | t.Fatal("Search() error = nil, want error") | |||
| 701 | + | } | |||
| 702 | + | } | |||
M internal/infrastructure/transcoder/audio.go +2 -0
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
M internal/infrastructure/transcoder/ffmpeg_offsets.go +2 -0
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | // Offset-based accessors for FFmpeg struct fields not yet exposed | 5 | // Offset-based accessors for FFmpeg struct fields not yet exposed | ||
A internal/infrastructure/transcoder/http_client_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package transcoder | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/url" | |||
| 10 | + | "testing" | |||
| 11 | + | ||||
| 12 | + | "github.com/rs/zerolog" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) { | |||
| 18 | + | source, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 24 | + | if err == nil { | |||
| 25 | + | t.Fatal("validateHTTPTranscodeURL() expected error for loopback target") | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 30 | + | tests := []string{ | |||
| 31 | + | "100.64.0.1", | |||
| 32 | + | "198.18.0.1", | |||
| 33 | + | "192.0.2.1", | |||
| 34 | + | "64:ff9b::a00:1", | |||
| 35 | + | "2001::1", | |||
| 36 | + | "2001:db8::1", | |||
| 37 | + | "2002:0a00:0001::1", | |||
| 38 | + | } | |||
| 39 | + | for _, rawIP := range tests { | |||
| 40 | + | t.Run(rawIP, func(t *testing.T) { | |||
| 41 | + | if !isBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 42 | + | t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 43 | + | } | |||
| 44 | + | }) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) { | |||
| 49 | + | source, err := url.Parse("file:///etc/passwd") | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 55 | + | if err == nil { | |||
| 56 | + | t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) { | |||
| 61 | + | source, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 67 | + | if err == nil { | |||
| 68 | + | t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) { | |||
| 73 | + | source, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | if err := validateHTTPTranscodeURL(context.Background(), source); err != nil { | |||
| 79 | + | t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) { | |||
| 84 | + | p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop()) | |||
| 85 | + | ||||
| 86 | + | _, _, err := p.openInputFormatContext(context.Background()) | |||
| 87 | + | if err == nil { | |||
| 88 | + | t.Fatal("openInputFormatContext() expected error for streaming manifest") | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) { | |||
| 93 | + | resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}} | |||
| 94 | + | if !isStreamingManifestResponse(resp) { | |||
| 95 | + | t.Fatal("isStreamingManifestResponse() = false, want true") | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) { | |||
| 100 | + | requestURL, err := url.Parse("http://10.0.0.1/private.mp4") | |||
| 101 | + | if err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 106 | + | if err == nil { | |||
| 107 | + | t.Fatal("validateTranscodeRedirect() expected error for private redirect target") | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) { | |||
| 112 | + | requestURL, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | via := make([]*http.Request, 10) | |||
| 118 | + | for i := range via { | |||
| 119 | + | via[i] = &http.Request{URL: requestURL} | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via) | |||
| 123 | + | if err == nil { | |||
| 124 | + | t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect") | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |||
| 129 | + | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 135 | + | if err == nil { | |||
| 136 | + | t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target") | |||
| 137 | + | } | |||
| 138 | + | } | |||
M internal/infrastructure/transcoder/hwprobe.go +2 -0
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
M internal/infrastructure/transcoder/hwprobe_test.go +2 -0
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import "testing" | 5 | import "testing" | ||
M internal/infrastructure/transcoder/pipeline.go +112 -110
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
| @@ -5,13 +7,12 @@ import ( | |||||
| 5 | "errors" | 7 | "errors" | ||
| 6 | "fmt" | 8 | "fmt" | ||
| 7 | "io" | 9 | "io" | ||
| 10 | + | "net" | |||
| 8 | "net/http" | 11 | "net/http" | ||
| 9 | - | "sort" | 12 | + | "net/url" |
| 10 | "strings" | 13 | "strings" | ||
| 11 | - | "unsafe" | |||
| 12 | - | ||||
| 13 | - | "net" | |||
| 14 | "time" | 14 | "time" | ||
| 15 | + | "unsafe" | |||
| 15 | 16 | ||||
| 16 | "github.com/bnema/purego" | 17 | "github.com/bnema/purego" | ||
| 17 | "github.com/rs/zerolog" | 18 | "github.com/rs/zerolog" | ||
| @@ -19,6 +20,7 @@ import ( | |||||
| 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | 20 | "github.com/bnema/purego-ffmpeg/ffmpeg" | ||
| 20 | 21 | ||||
| 21 | "github.com/bnema/dumber/internal/application/port" | 22 | "github.com/bnema/dumber/internal/application/port" | ||
| 23 | + | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 22 | ) | 24 | ) | ||
| 23 | 25 | ||||
| 24 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | 26 | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | ||
| @@ -53,10 +55,104 @@ type pipeline struct { | |||||
| 53 | // are not killed. The session context handles cancellation. | 55 | // are not killed. The session context handles cancellation. | ||
| 54 | var httpClient = &http.Client{ | 56 | var httpClient = &http.Client{ | ||
| 55 | Transport: &http.Transport{ | 57 | Transport: &http.Transport{ | ||
| 56 | - | DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, | 58 | + | DialContext: safeTranscodeDialContext, |
| 57 | TLSHandshakeTimeout: 10 * time.Second, | 59 | TLSHandshakeTimeout: 10 * time.Second, | ||
| 58 | ResponseHeaderTimeout: 15 * time.Second, | 60 | ResponseHeaderTimeout: 15 * time.Second, | ||
| 59 | }, | 61 | }, | ||
| 62 | + | CheckRedirect: validateTranscodeRedirect, | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |||
| 66 | + | if len(via) >= 10 { | |||
| 67 | + | return fmt.Errorf("stopped after 10 redirects") | |||
| 68 | + | } | |||
| 69 | + | if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil { | |||
| 70 | + | return fmt.Errorf("blocked redirect target: %w", err) | |||
| 71 | + | } | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |||
| 76 | + | host, portValue, err := net.SplitHostPort(address) | |||
| 77 | + | if err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | ips, err := resolveAllowedTranscodeIPs(ctx, host) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return nil, err | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | dialer := &net.Dialer{Timeout: 10 * time.Second} | |||
| 87 | + | var lastErr error | |||
| 88 | + | for _, ip := range ips { | |||
| 89 | + | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue)) | |||
| 90 | + | if dialErr == nil { | |||
| 91 | + | return conn, nil | |||
| 92 | + | } | |||
| 93 | + | lastErr = dialErr | |||
| 94 | + | } | |||
| 95 | + | if lastErr != nil { | |||
| 96 | + | return nil, lastErr | |||
| 97 | + | } | |||
| 98 | + | return nil, fmt.Errorf("no allowed transcode source addresses for %s", host) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error { | |||
| 102 | + | if source == nil || source.Host == "" { | |||
| 103 | + | return fmt.Errorf("invalid src") | |||
| 104 | + | } | |||
| 105 | + | if source.Scheme != "http" && source.Scheme != "https" { | |||
| 106 | + | return fmt.Errorf("unsupported src scheme") | |||
| 107 | + | } | |||
| 108 | + | if IsStreamingManifestURL(source.String()) { | |||
| 109 | + | return fmt.Errorf("streaming manifest transcoding is disabled") | |||
| 110 | + | } | |||
| 111 | + | _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname()) | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) { | |||
| 116 | + | host = strings.TrimSpace(host) | |||
| 117 | + | if host == "" { | |||
| 118 | + | return nil, fmt.Errorf("invalid src host") | |||
| 119 | + | } | |||
| 120 | + | if ip := net.ParseIP(host); ip != nil { | |||
| 121 | + | if isBlockedTranscodeIP(ip) { | |||
| 122 | + | return nil, fmt.Errorf("private src host not allowed") | |||
| 123 | + | } | |||
| 124 | + | return []net.IP{ip}, nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 128 | + | defer cancel() | |||
| 129 | + | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 130 | + | if err != nil || len(addrs) == 0 { | |||
| 131 | + | return nil, fmt.Errorf("src host could not be resolved") | |||
| 132 | + | } | |||
| 133 | + | ips := make([]net.IP, 0, len(addrs)) | |||
| 134 | + | for _, addr := range addrs { | |||
| 135 | + | if isBlockedTranscodeIP(addr.IP) { | |||
| 136 | + | return nil, fmt.Errorf("private src host not allowed") | |||
| 137 | + | } | |||
| 138 | + | ips = append(ips, addr.IP) | |||
| 139 | + | } | |||
| 140 | + | return ips, nil | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 144 | + | return netguard.IsBlockedTranscodeIP(ip) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func isStreamingManifestResponse(resp *http.Response) bool { | |||
| 148 | + | if resp == nil { | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) { | |||
| 152 | + | return true | |||
| 153 | + | } | |||
| 154 | + | contentType := strings.ToLower(resp.Header.Get("Content-Type")) | |||
| 155 | + | return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml") | |||
| 60 | } | 156 | } | ||
| 61 | 157 | ||||
| 62 | func newPipeline( | 158 | func newPipeline( | ||
| @@ -394,7 +490,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI | |||||
| 394 | 490 | ||||
| 395 | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | 491 | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | ||
| 396 | if IsStreamingManifestURL(p.sourceURL) { | 492 | if IsStreamingManifestURL(p.sourceURL) { | ||
| 397 | - | return p.openManifestInputContext() | 493 | + | // FFmpeg resolves manifest redirects and segment URLs internally, bypassing |
| 494 | + | // the guarded Go HTTP client below. Keep untrusted transcode requests on | |||
| 495 | + | // custom I/O until manifests can be fetched and rewritten through the same | |||
| 496 | + | // SSRF protections. | |||
| 497 | + | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 398 | } | 498 | } | ||
| 399 | return p.openCustomIOInputContext(ctx) | 499 | return p.openCustomIOInputContext(ctx) | ||
| 400 | } | 500 | } | ||
| @@ -417,6 +517,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||||
| 417 | _ = resp.Body.Close() | 517 | _ = resp.Body.Close() | ||
| 418 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | 518 | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | ||
| 419 | } | 519 | } | ||
| 520 | + | if isStreamingManifestResponse(resp) { | |||
| 521 | + | _ = resp.Body.Close() | |||
| 522 | + | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 523 | + | } | |||
| 420 | 524 | ||||
| 421 | body := resp.Body | 525 | body := resp.Body | ||
| 422 | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | 526 | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | ||
| @@ -478,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer | |||||
| 478 | return inFmtCtx, cleanup, nil | 582 | return inFmtCtx, cleanup, nil | ||
| 479 | } | 583 | } | ||
| 480 | 584 | ||||
| 481 | - | func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) { | |||
| 482 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 483 | - | if inFmtCtx == nil { | |||
| 484 | - | return nil, nil, errors.New("failed to allocate input format context") | |||
| 485 | - | } | |||
| 486 | - | ||||
| 487 | - | var opts unsafe.Pointer | |||
| 488 | - | defer freeInputOptions(&opts) | |||
| 489 | - | ||||
| 490 | - | if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil { | |||
| 491 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 492 | - | return nil, nil, err | |||
| 493 | - | } | |||
| 494 | - | if userAgent := p.headers["User-Agent"]; userAgent != "" { | |||
| 495 | - | if err := setInputOption(&opts, "user_agent", userAgent); err != nil { | |||
| 496 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 497 | - | return nil, nil, err | |||
| 498 | - | } | |||
| 499 | - | } | |||
| 500 | - | if referer := p.headers["Referer"]; referer != "" { | |||
| 501 | - | if err := setInputOption(&opts, "referer", referer); err != nil { | |||
| 502 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 503 | - | return nil, nil, err | |||
| 504 | - | } | |||
| 505 | - | } | |||
| 506 | - | ||||
| 507 | - | headerLines := formatHTTPHeaderOptions(p.headers) | |||
| 508 | - | if headerLines != "" { | |||
| 509 | - | if err := setInputOption(&opts, "headers", headerLines); err != nil { | |||
| 510 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 511 | - | return nil, nil, err | |||
| 512 | - | } | |||
| 513 | - | } | |||
| 514 | - | ||||
| 515 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries. | |||
| 516 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 { | |||
| 517 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 518 | - | return nil, nil, fmt.Errorf("open manifest input: %d", ret) | |||
| 519 | - | } | |||
| 520 | - | ||||
| 521 | - | return inFmtCtx, func() { | |||
| 522 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |||
| 523 | - | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |||
| 524 | - | }, nil | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | func freeInputOptions(opts *unsafe.Pointer) { | |||
| 528 | - | if opts == nil || *opts == nil { | |||
| 529 | - | return | |||
| 530 | - | } | |||
| 531 | - | // av_dict_free expects an AVDictionary**. Passing the dictionary pointer | |||
| 532 | - | // itself corrupts memory and can crash inside libavutil during cleanup. | |||
| 533 | - | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup. | |||
| 534 | - | ffmpeg.DictFree(unsafe.Pointer(opts)) | |||
| 535 | - | *opts = nil | |||
| 536 | - | } | |||
| 537 | - | ||||
| 538 | - | func setInputOption(opts *unsafe.Pointer, key, value string) error { | |||
| 539 | - | if value == "" { | |||
| 540 | - | return nil | |||
| 541 | - | } | |||
| 542 | - | //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates. | |||
| 543 | - | if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 { | |||
| 544 | - | return fmt.Errorf("set input option %q: %d", key, ret) | |||
| 545 | - | } | |||
| 546 | - | return nil | |||
| 547 | - | } | |||
| 548 | - | ||||
| 549 | - | func formatHTTPHeaderOptions(headers map[string]string) string { | |||
| 550 | - | if len(headers) == 0 { | |||
| 551 | - | return "" | |||
| 552 | - | } | |||
| 553 | - | ||||
| 554 | - | keys := make([]string, 0, len(headers)) | |||
| 555 | - | for key, value := range headers { | |||
| 556 | - | if strings.TrimSpace(value) == "" { | |||
| 557 | - | continue | |||
| 558 | - | } | |||
| 559 | - | if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") { | |||
| 560 | - | continue | |||
| 561 | - | } | |||
| 562 | - | keys = append(keys, key) | |||
| 563 | - | } | |||
| 564 | - | if len(keys) == 0 { | |||
| 565 | - | return "" | |||
| 566 | - | } | |||
| 567 | - | ||||
| 568 | - | sort.Strings(keys) | |||
| 569 | - | ||||
| 570 | - | var builder strings.Builder | |||
| 571 | - | for _, key := range keys { | |||
| 572 | - | builder.WriteString(key) | |||
| 573 | - | builder.WriteString(": ") | |||
| 574 | - | builder.WriteString(headers[key]) | |||
| 575 | - | builder.WriteString("\r\n") | |||
| 576 | - | } | |||
| 577 | - | return builder.String() | |||
| 578 | - | } | |||
| 579 | - | ||||
| 580 | - | // openVideoDecoder creates and opens a decoder context for the video stream. | |||
| 581 | - | // It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps, | |||
| 582 | - | // falling back to the generic software decoder. | |||
| 583 | - | // | |||
| 584 | - | //nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup. | |||
| 585 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | 585 | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | ||
| 586 | codecID := ffmpeg.CodecParCodecID(codecPar) | 586 | codecID := ffmpeg.CodecParCodecID(codecPar) | ||
| 587 | 587 | ||||
| @@ -608,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er | |||||
| 608 | } | 608 | } | ||
| 609 | 609 | ||||
| 610 | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | 610 | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | ||
| 611 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 611 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 612 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | ||
| 612 | return nil, fmt.Errorf("copy video codec params: %d", ret) | 613 | return nil, fmt.Errorf("copy video codec params: %d", ret) | ||
| 613 | } | 614 | } | ||
| 614 | 615 | ||||
| 615 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | 616 | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | ||
| 617 | + | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 616 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | 618 | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | ||
| 617 | return nil, fmt.Errorf("open video decoder: %d", ret) | 619 | return nil, fmt.Errorf("open video decoder: %d", ret) | ||
| 618 | } | 620 | } | ||
M internal/infrastructure/transcoder/transcoder.go +2 -0
| @@ -1,3 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 1 | package transcoder | 3 | package transcoder | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
A internal/infrastructure/transcoder/transcoder_wasm.go +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package transcoder | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | ||||
| 9 | + | "github.com/rs/zerolog" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Compile-time check: Transcoder implements port.MediaTranscoder. | |||
| 15 | + | var _ port.MediaTranscoder = (*Transcoder)(nil) | |||
| 16 | + | ||||
| 17 | + | // Transcoder is unavailable in js/wasm builds because FFmpeg is not available. | |||
| 18 | + | type Transcoder struct{} | |||
| 19 | + | ||||
| 20 | + | // New creates an unavailable Transcoder for js/wasm builds. | |||
| 21 | + | func New(_ any, _ *zerolog.Logger) *Transcoder { | |||
| 22 | + | return &Transcoder{} | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Available reports whether transcoding is available. | |||
| 26 | + | func (*Transcoder) Available() bool { | |||
| 27 | + | return false | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // Capabilities returns empty hardware capabilities for js/wasm builds. | |||
| 31 | + | func (*Transcoder) Capabilities() port.HWCapabilities { | |||
| 32 | + | return port.HWCapabilities{} | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Start always fails for js/wasm builds. | |||
| 36 | + | func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) { | |||
| 37 | + | return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Close is a no-op for js/wasm builds. | |||
| 41 | + | func (*Transcoder) Close() {} | |||
M internal/infrastructure/webkit/engine_init.go +3 -0
| @@ -25,6 +25,8 @@ func NewEngine( | |||||
| 25 | opts port.EngineOptions, | 25 | opts port.EngineOptions, | ||
| 26 | profile runtimeprofile.Profile, | 26 | profile runtimeprofile.Profile, | ||
| 27 | wkCfg WebKitEngineConfig, | 27 | wkCfg WebKitEngineConfig, | ||
| 28 | + | currentConfigPayload func() ([]byte, error), | |||
| 29 | + | defaultConfigPayload func() ([]byte, error), | |||
| 28 | themeManager *theme.Manager, | 30 | themeManager *theme.Manager, | ||
| 29 | colorResolver port.ColorSchemeResolver, | 31 | colorResolver port.ColorSchemeResolver, | ||
| 30 | contextMenuBuilder port.ContextMenuBuilder, | 32 | contextMenuBuilder port.ContextMenuBuilder, | ||
| @@ -56,6 +58,7 @@ func NewEngine( | |||||
| 56 | 58 | ||||
| 57 | // --- Scheme handler --- | 59 | // --- Scheme handler --- | ||
| 58 | schemeHandler := NewDumbSchemeHandler(ctx) | 60 | schemeHandler := NewDumbSchemeHandler(ctx) | ||
| 61 | + | schemeHandler.SetConfigPayloadBuilders(currentConfigPayload, defaultConfigPayload) | |||
| 59 | schemeHandler.SetAssets(assets.WebUIAssets) | 62 | schemeHandler.SetAssets(assets.WebUIAssets) | ||
| 60 | schemeHandler.RegisterWithContext(wkCtx) | 63 | schemeHandler.RegisterWithContext(wkCtx) | ||
| 61 | 64 | ||||
A internal/infrastructure/webkit/favicon_systemviews.go +14 -0
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 5 | + | var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil) | |||
| 6 | + | ||||
| 7 | + | // SetSystemviewFaviconService exposes cached favicons to systemview pages via | |||
| 8 | + | // dumb://api/favicon without triggering remote favicon fetches during History rendering. | |||
| 9 | + | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |||
| 10 | + | if e == nil || e.schemeHandler == nil { | |||
| 11 | + | return | |||
| 12 | + | } | |||
| 13 | + | e.schemeHandler.SetFaviconService(service) | |||
| 14 | + | } | |||
M internal/infrastructure/webkit/scheme_handler.go +314 -87
| @@ -1,20 +1,23 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "embed" | 6 | "embed" | ||
| 6 | - | "encoding/json" | |||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "io" | |||
| 8 | "io/fs" | 9 | "io/fs" | ||
| 9 | "net/http" | 10 | "net/http" | ||
| 10 | "net/url" | 11 | "net/url" | ||
| 11 | - | "path/filepath" | 12 | + | "os" |
| 13 | + | "path" | |||
| 14 | + | "strconv" | |||
| 12 | "strings" | 15 | "strings" | ||
| 13 | "sync" | 16 | "sync" | ||
| 14 | 17 | ||||
| 18 | + | "github.com/andybalholm/brotli" | |||
| 15 | "github.com/bnema/dumber/internal/application/port" | 19 | "github.com/bnema/dumber/internal/application/port" | ||
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/config" | 20 | + | domainurl "github.com/bnema/dumber/internal/domain/url" |
| 17 | - | "github.com/bnema/dumber/internal/infrastructure/env" | |||
| 18 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 21 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 19 | "github.com/bnema/dumber/internal/logging" | 22 | "github.com/bnema/dumber/internal/logging" | ||
| 20 | "github.com/bnema/puregotk/v4/gio" | 23 | "github.com/bnema/puregotk/v4/gio" | ||
| @@ -25,29 +28,35 @@ import ( | |||||
| 25 | 28 | ||||
| 26 | // Scheme path constants | 29 | // Scheme path constants | ||
| 27 | const ( | 30 | const ( | ||
| 28 | - | HomePath = "home" | 31 | + | HistoryPath = "history" |
| 29 | - | ConfigPath = "config" | 32 | + | FavoritesPath = "favorites" |
| 30 | - | WebRTCPath = "webrtc" | 33 | + | ConfigPath = "config" |
| 31 | - | ErrorPath = "error" | 34 | + | ErrorPath = "error" |
| 32 | - | CrashPath = "crash" | 35 | + | CrashPath = "crash" |
| 33 | - | IndexHTML = "index.html" | 36 | + | IndexHTML = "index.html" |
| 34 | - | httpGET = "GET" | 37 | + | httpGET = "GET" |
| 38 | + | maxSystemviewsWASMBytes = 64 * 1024 * 1024 | |||
| 39 | + | systemviewsAssetDir = "systemviews" | |||
| 35 | ) | 40 | ) | ||
| 36 | 41 | ||||
| 37 | // SchemeRequest represents a request to a custom URI scheme. | 42 | // SchemeRequest represents a request to a custom URI scheme. | ||
| 38 | type SchemeRequest struct { | 43 | type SchemeRequest struct { | ||
| 39 | - | inner *webkit.URISchemeRequest | 44 | + | inner *webkit.URISchemeRequest |
| 40 | - | URI string | 45 | + | URI string |
| 41 | - | Path string | 46 | + | Path string |
| 42 | - | Method string | 47 | + | Method string |
| 43 | - | Scheme string | 48 | + | Scheme string |
| 49 | + | Origin string | |||
| 50 | + | Referer string | |||
| 44 | } | 51 | } | ||
| 45 | 52 | ||||
| 46 | // SchemeResponse represents a response to a scheme request. | 53 | // SchemeResponse represents a response to a scheme request. | ||
| 47 | type SchemeResponse struct { | 54 | type SchemeResponse struct { | ||
| 48 | - | Data []byte | 55 | + | Data []byte |
| 49 | - | ContentType string | 56 | + | ContentType string |
| 50 | - | StatusCode int | 57 | + | StatusCode int |
| 58 | + | Headers map[string]string | |||
| 59 | + | SuppressDefaultHeaders bool | |||
| 51 | } | 60 | } | ||
| 52 | 61 | ||||
| 53 | // PageHandler generates content for a specific page path. | 62 | // PageHandler generates content for a specific page path. | ||
| @@ -64,13 +73,14 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||||
| 64 | 73 | ||||
| 65 | // DumbSchemeHandler handles dumb:// URI scheme requests. | 74 | // DumbSchemeHandler handles dumb:// URI scheme requests. | ||
| 66 | type DumbSchemeHandler struct { | 75 | type DumbSchemeHandler struct { | ||
| 67 | - | handlers map[string]PageHandler | 76 | + | handlers map[string]PageHandler |
| 68 | - | assets embed.FS | 77 | + | assets embed.FS |
| 69 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | 78 | + | faviconService port.FaviconService |
| 70 | - | logger zerolog.Logger | 79 | + | assetDir string // default subdirectory within embed.FS (e.g., "systemviews") |
| 71 | - | mu sync.RWMutex | 80 | + | logger zerolog.Logger |
| 72 | - | hwSurveyor *env.HardwareSurveyor | 81 | + | mu sync.RWMutex |
| 73 | - | ctx context.Context | 82 | + | currentConfigPayload func() ([]byte, error) |
| 83 | + | defaultConfigPayload func() ([]byte, error) | |||
| 74 | } | 84 | } | ||
| 75 | 85 | ||||
| 76 | // NewDumbSchemeHandler creates a new handler for the dumb:// scheme. | 86 | // NewDumbSchemeHandler creates a new handler for the dumb:// scheme. | ||
| @@ -78,11 +88,9 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 78 | log := logging.FromContext(ctx) | 88 | log := logging.FromContext(ctx) | ||
| 79 | 89 | ||||
| 80 | h := &DumbSchemeHandler{ | 90 | h := &DumbSchemeHandler{ | ||
| 81 | - | handlers: make(map[string]PageHandler), | 91 | + | handlers: make(map[string]PageHandler), |
| 82 | - | assetDir: "webui", | 92 | + | assetDir: systemviewsAssetDir, |
| 83 | - | logger: log.With().Str("component", "scheme-handler").Logger(), | 93 | + | logger: log.With().Str("component", "scheme-handler").Logger(), |
| 84 | - | hwSurveyor: env.NewHardwareSurveyor(), | |||
| 85 | - | ctx: ctx, | |||
| 86 | } | 94 | } | ||
| 87 | 95 | ||||
| 88 | // Register default pages | 96 | // Register default pages | ||
| @@ -91,7 +99,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | |||||
| 91 | return h | 99 | return h | ||
| 92 | } | 100 | } | ||
| 93 | 101 | ||||
| 94 | - | // SetAssets sets the embedded filesystem containing webui assets. | 102 | + | // SetAssets sets the embedded filesystem containing systemviews assets. |
| 95 | func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | 103 | func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | ||
| 96 | h.mu.Lock() | 104 | h.mu.Lock() | ||
| 97 | defer h.mu.Unlock() | 105 | defer h.mu.Unlock() | ||
| @@ -99,6 +107,21 @@ func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | |||||
| 99 | h.logger.Debug().Msg("assets filesystem configured") | 107 | h.logger.Debug().Msg("assets filesystem configured") | ||
| 100 | } | 108 | } | ||
| 101 | 109 | ||||
| 110 | + | // SetConfigPayloadBuilders wires the config payload builders used by /api/config. | |||
| 111 | + | func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) { | |||
| 112 | + | h.mu.Lock() | |||
| 113 | + | defer h.mu.Unlock() | |||
| 114 | + | h.currentConfigPayload = current | |||
| 115 | + | h.defaultConfigPayload = defaultPayload | |||
| 116 | + | h.logger.Debug().Msg("config payload builders configured") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) { | |||
| 120 | + | h.mu.Lock() | |||
| 121 | + | defer h.mu.Unlock() | |||
| 122 | + | h.faviconService = service | |||
| 123 | + | } | |||
| 124 | + | ||||
| 102 | // registerDefaults sets up default page handlers. | 125 | // registerDefaults sets up default page handlers. | ||
| 103 | func (h *DumbSchemeHandler) registerDefaults() { | 126 | func (h *DumbSchemeHandler) registerDefaults() { | ||
| 104 | // Error page (static fallback) | 127 | // Error page (static fallback) | ||
| @@ -128,8 +151,14 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 128 | if req.Method != "" && req.Method != httpGET { | 151 | if req.Method != "" && req.Method != httpGET { | ||
| 129 | return nil | 152 | return nil | ||
| 130 | } | 153 | } | ||
| 154 | + | if !isTrustedSystemviewAPIRequest(req) { | |||
| 155 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |||
| 156 | + | } | |||
| 131 | 157 | ||||
| 132 | - | return h.buildConfigResponse(config.Get()) | 158 | + | h.mu.RLock() |
| 159 | + | build := h.currentConfigPayload | |||
| 160 | + | h.mu.RUnlock() | |||
| 161 | + | return buildConfigResponse(build) | |||
| 133 | })) | 162 | })) | ||
| 134 | 163 | ||||
| 135 | // API: Get default config (used by Reset Defaults in dumb://config) | 164 | // API: Get default config (used by Reset Defaults in dumb://config) | ||
| @@ -137,8 +166,21 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 137 | if req.Method != "" && req.Method != httpGET { | 166 | if req.Method != "" && req.Method != httpGET { | ||
| 138 | return nil | 167 | return nil | ||
| 139 | } | 168 | } | ||
| 169 | + | if !isTrustedSystemviewAPIRequest(req) { | |||
| 170 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |||
| 171 | + | } | |||
| 140 | 172 | ||||
| 141 | - | return h.buildConfigResponse(config.DefaultConfig()) | 173 | + | h.mu.RLock() |
| 174 | + | build := h.defaultConfigPayload | |||
| 175 | + | h.mu.RUnlock() | |||
| 176 | + | return buildConfigResponse(build) | |||
| 177 | + | })) | |||
| 178 | + | ||||
| 179 | + | h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | |||
| 180 | + | if req.Method != "" && req.Method != httpGET { | |||
| 181 | + | return nil | |||
| 182 | + | } | |||
| 183 | + | return h.handleFaviconAPI(req) | |||
| 142 | })) | 184 | })) | ||
| 143 | } | 185 | } | ||
| 144 | 186 | ||||
| @@ -161,40 +203,138 @@ func buildCrashPageHTML(originalURI string) string { | |||||
| 161 | return webutil.BuildCrashPageHTML(originalURI) | 203 | return webutil.BuildCrashPageHTML(originalURI) | ||
| 162 | } | 204 | } | ||
| 163 | 205 | ||||
| 164 | - | func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse { | 206 | + | const systemviewFaviconSize = 32 |
| 165 | - | // Get hardware info for display and profile resolution | |||
| 166 | - | // Use background context since survey results are cached and we don't want | |||
| 167 | - | // request context cancellation to affect this | |||
| 168 | - | var hw *port.HardwareInfo | |||
| 169 | - | if h.hwSurveyor != nil { | |||
| 170 | - | hwInfo := h.hwSurveyor.Survey(context.Background()) | |||
| 171 | - | hw = &hwInfo | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | resp := config.BuildWebUIConfigPayload(cfg, hw) | |||
| 175 | 207 | ||||
| 176 | - | data, err := json.Marshal(resp) | 208 | + | func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse { |
| 209 | + | if !isTrustedSystemviewFaviconRequest(req) { | |||
| 210 | + | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |||
| 211 | + | } | |||
| 212 | + | h.mu.RLock() | |||
| 213 | + | service := h.faviconService | |||
| 214 | + | h.mu.RUnlock() | |||
| 215 | + | if service == nil { | |||
| 216 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable") | |||
| 217 | + | } | |||
| 218 | + | if req == nil { | |||
| 219 | + | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request") | |||
| 220 | + | } | |||
| 221 | + | parsed, err := url.Parse(req.URI) | |||
| 177 | if err != nil { | 222 | if err != nil { | ||
| 178 | - | return &SchemeResponse{ | 223 | + | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL") |
| 179 | - | Data: []byte(fmt.Sprintf(`{"error": %q}`, err)), | 224 | + | } |
| 180 | - | ContentType: "application/json", | 225 | + | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) |
| 181 | - | StatusCode: http.StatusInternalServerError, | 226 | + | if domain == "" { |
| 227 | + | return privateJSONErrorResponse(http.StatusBadRequest, "missing domain") | |||
| 228 | + | } | |||
| 229 | + | size := systemviewFaviconSize | |||
| 230 | + | if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" { | |||
| 231 | + | parsedSize, parseErr := strconv.Atoi(rawSize) | |||
| 232 | + | if parseErr != nil || parsedSize != systemviewFaviconSize { | |||
| 233 | + | return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size") | |||
| 182 | } | 234 | } | ||
| 235 | + | size = parsedSize | |||
| 183 | } | 236 | } | ||
| 184 | 237 | ||||
| 238 | + | if !service.HasPNGSizedOnDisk(domain, size) { | |||
| 239 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |||
| 240 | + | } | |||
| 241 | + | diskPath := service.DiskPathPNGSized(domain, size) | |||
| 242 | + | if diskPath == "" { | |||
| 243 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |||
| 244 | + | } | |||
| 245 | + | data, err := os.ReadFile(diskPath) | |||
| 246 | + | if err != nil || len(data) == 0 { | |||
| 247 | + | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |||
| 248 | + | } | |||
| 185 | return &SchemeResponse{ | 249 | return &SchemeResponse{ | ||
| 186 | - | Data: data, | 250 | + | Data: data, |
| 251 | + | ContentType: "image/png", | |||
| 252 | + | StatusCode: http.StatusOK, | |||
| 253 | + | Headers: map[string]string{"Cache-Control": "no-store"}, | |||
| 254 | + | SuppressDefaultHeaders: true, | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool { | |||
| 259 | + | return isTrustedSystemviewAPIRequest(req) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool { | |||
| 263 | + | if req == nil { | |||
| 264 | + | return false | |||
| 265 | + | } | |||
| 266 | + | origin := strings.TrimSpace(req.Origin) | |||
| 267 | + | if origin != "" { | |||
| 268 | + | return isTrustedSystemviewURL(origin) | |||
| 269 | + | } | |||
| 270 | + | return isTrustedSystemviewURL(req.Referer) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func isTrustedSystemviewURL(raw string) bool { | |||
| 274 | + | parsed, err := url.Parse(strings.TrimSpace(raw)) | |||
| 275 | + | if err != nil || parsed.Scheme == "" { | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | if !strings.EqualFold(parsed.Scheme, "dumb") { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | host := parsed.Host | |||
| 282 | + | if host == "" { | |||
| 283 | + | host = parsed.Opaque | |||
| 284 | + | } | |||
| 285 | + | if idx := strings.IndexAny(host, "/?#"); idx >= 0 { | |||
| 286 | + | host = host[:idx] | |||
| 287 | + | } | |||
| 288 | + | switch host { | |||
| 289 | + | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath: | |||
| 290 | + | return true | |||
| 291 | + | default: | |||
| 292 | + | return false | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func jsonErrorResponse(status int, message string) *SchemeResponse { | |||
| 297 | + | return &SchemeResponse{ | |||
| 298 | + | Data: []byte(fmt.Sprintf(`{"error":%q}`, message)), | |||
| 187 | ContentType: "application/json", | 299 | ContentType: "application/json", | ||
| 188 | - | StatusCode: http.StatusOK, | 300 | + | StatusCode: status, |
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func privateJSONErrorResponse(status int, message string) *SchemeResponse { | |||
| 305 | + | resp := jsonErrorResponse(status, message) | |||
| 306 | + | resp.Headers = map[string]string{"Cache-Control": "no-store"} | |||
| 307 | + | resp.SuppressDefaultHeaders = true | |||
| 308 | + | return resp | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse { | |||
| 312 | + | if build == nil { | |||
| 313 | + | resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured") | |||
| 314 | + | return resp | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | data, err := build() | |||
| 318 | + | if err != nil { | |||
| 319 | + | resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error()) | |||
| 320 | + | return resp | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | return &SchemeResponse{ | |||
| 324 | + | Data: data, | |||
| 325 | + | ContentType: "application/json", | |||
| 326 | + | StatusCode: http.StatusOK, | |||
| 327 | + | Headers: map[string]string{"Cache-Control": "no-store"}, | |||
| 328 | + | SuppressDefaultHeaders: true, | |||
| 189 | } | 329 | } | ||
| 190 | } | 330 | } | ||
| 191 | 331 | ||||
| 192 | // RegisterPage registers a handler for a specific path. | 332 | // RegisterPage registers a handler for a specific path. | ||
| 193 | - | func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) { | 333 | + | func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) { |
| 194 | h.mu.Lock() | 334 | h.mu.Lock() | ||
| 195 | defer h.mu.Unlock() | 335 | defer h.mu.Unlock() | ||
| 196 | - | h.handlers[path] = handler | 336 | + | h.handlers[pagePath] = handler |
| 197 | - | h.logger.Debug().Str("path", path).Msg("registered page handler") | 337 | + | h.logger.Debug().Str("path", pagePath).Msg("registered page handler") |
| 198 | } | 338 | } | ||
| 199 | 339 | ||||
| 200 | // HandleRequest processes a scheme request and sends the response. | 340 | // HandleRequest processes a scheme request and sends the response. | ||
| @@ -205,6 +345,7 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 205 | } | 345 | } | ||
| 206 | 346 | ||||
| 207 | uri := req.GetUri() | 347 | uri := req.GetUri() | ||
| 348 | + | requestHeaders := req.GetHttpHeaders() | |||
| 208 | schemeReq := &SchemeRequest{ | 349 | schemeReq := &SchemeRequest{ | ||
| 209 | inner: req, | 350 | inner: req, | ||
| 210 | URI: uri, | 351 | URI: uri, | ||
| @@ -212,6 +353,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 212 | Method: req.GetHttpMethod(), | 353 | Method: req.GetHttpMethod(), | ||
| 213 | Scheme: req.GetScheme(), | 354 | Scheme: req.GetScheme(), | ||
| 214 | } | 355 | } | ||
| 356 | + | if requestHeaders != nil { | |||
| 357 | + | schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin")) | |||
| 358 | + | schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer")) | |||
| 359 | + | } | |||
| 215 | 360 | ||||
| 216 | h.logger.Debug(). | 361 | h.logger.Debug(). | ||
| 217 | Str("uri", schemeReq.URI). | 362 | Str("uri", schemeReq.URI). | ||
| @@ -279,21 +424,26 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 279 | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | 424 | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | ||
| 280 | h.mu.RLock() | 425 | h.mu.RLock() | ||
| 281 | hasAssets := h.assets != (embed.FS{}) | 426 | hasAssets := h.assets != (embed.FS{}) | ||
| 282 | - | assetDir := h.assetDir | |||
| 283 | h.mu.RUnlock() | 427 | h.mu.RUnlock() | ||
| 284 | 428 | ||||
| 285 | if !hasAssets { | 429 | if !hasAssets { | ||
| 286 | return nil | 430 | return nil | ||
| 287 | } | 431 | } | ||
| 288 | 432 | ||||
| 289 | - | relPath, ok := resolveAssetPath(u) | 433 | + | assetDir, relPath, ok := resolveAssetPath(u) |
| 434 | + | if !ok { | |||
| 435 | + | return nil | |||
| 436 | + | } | |||
| 437 | + | if assetDir == "" { | |||
| 438 | + | assetDir = h.assetDir | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath) | |||
| 290 | if !ok { | 442 | if !ok { | ||
| 291 | return nil | 443 | return nil | ||
| 292 | } | 444 | } | ||
| 293 | 445 | ||||
| 294 | - | // Read the asset from embedded FS | 446 | + | data, err := readAssetWithEncoding(h.assets, fullPath, relPath) |
| 295 | - | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |||
| 296 | - | data, err := fs.ReadFile(h.assets, fullPath) | |||
| 297 | if err != nil { | 447 | if err != nil { | ||
| 298 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | 448 | h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found") | ||
| 299 | return nil | 449 | return nil | ||
| @@ -310,43 +460,111 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 310 | Data: data, | 460 | Data: data, | ||
| 311 | ContentType: contentType, | 461 | ContentType: contentType, | ||
| 312 | StatusCode: http.StatusOK, | 462 | StatusCode: http.StatusOK, | ||
| 463 | + | Headers: nil, | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) { | |||
| 468 | + | assetDir = strings.Trim(assetDir, "/") | |||
| 469 | + | if assetDir != systemviewsAssetDir { | |||
| 470 | + | return "", "", false | |||
| 313 | } | 471 | } | ||
| 472 | + | ||||
| 473 | + | relPath = strings.TrimLeft(relPath, "/") | |||
| 474 | + | if relPath == "" || strings.ContainsRune(relPath, '\x00') { | |||
| 475 | + | return "", "", false | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | cleanRelPath = path.Clean(relPath) | |||
| 479 | + | if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) { | |||
| 480 | + | return "", "", false | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | fullPath = path.Join(assetDir, cleanRelPath) | |||
| 484 | + | if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") { | |||
| 485 | + | return "", "", false | |||
| 486 | + | } | |||
| 487 | + | return fullPath, cleanRelPath, true | |||
| 314 | } | 488 | } | ||
| 315 | 489 | ||||
| 316 | - | func resolveAssetPath(u *url.URL) (string, bool) { | 490 | + | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { |
| 491 | + | var compressedErr error | |||
| 492 | + | if strings.HasSuffix(relPath, ".wasm") { | |||
| 493 | + | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |||
| 494 | + | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |||
| 495 | + | if err != nil { | |||
| 496 | + | compressedErr = err | |||
| 497 | + | } else if len(data) > maxSystemviewsWASMBytes { | |||
| 498 | + | compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes) | |||
| 499 | + | } else { | |||
| 500 | + | return data, nil | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | } | |||
| 504 | + | data, err := fs.ReadFile(assets, fullPath) | |||
| 505 | + | if err == nil { | |||
| 506 | + | return data, nil | |||
| 507 | + | } | |||
| 508 | + | if compressedErr != nil { | |||
| 509 | + | return nil, compressedErr | |||
| 510 | + | } | |||
| 511 | + | return nil, err | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 317 | if u == nil { | 515 | if u == nil { | ||
| 318 | - | return "", false | 516 | + | return "", "", false |
| 319 | } | 517 | } | ||
| 320 | 518 | ||||
| 321 | - | rootByHost := map[string]string{ | 519 | + | rootByHost := map[string]struct { |
| 322 | - | HomePath: IndexHTML, | 520 | + | assetDir string |
| 323 | - | ConfigPath: "config.html", | 521 | + | file string |
| 324 | - | WebRTCPath: "webrtc.html", | 522 | + | }{ |
| 325 | - | ErrorPath: "error.html", | 523 | + | HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, |
| 524 | + | FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |||
| 525 | + | ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |||
| 526 | + | ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |||
| 527 | + | CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML}, | |||
| 326 | } | 528 | } | ||
| 327 | 529 | ||||
| 328 | if root, ok := rootByHost[u.Host]; ok { | 530 | if root, ok := rootByHost[u.Host]; ok { | ||
| 329 | - | path := strings.TrimPrefix(u.Path, "/") | 531 | + | assetPath := strings.TrimPrefix(u.Path, "/") |
| 330 | - | if path == "" { | 532 | + | if assetPath == "" { |
| 331 | - | return root, true | 533 | + | return root.assetDir, root.file, true |
| 332 | } | 534 | } | ||
| 333 | - | return path, true | 535 | + | return root.assetDir, assetPath, true |
| 334 | } | 536 | } | ||
| 335 | 537 | ||||
| 336 | switch u.Opaque { | 538 | switch u.Opaque { | ||
| 337 | - | case HomePath: | 539 | + | case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath: |
| 338 | - | return IndexHTML, true | 540 | + | return systemviewsAssetDir, IndexHTML, true |
| 339 | - | case ConfigPath: | |||
| 340 | - | return "config.html", true | |||
| 341 | - | case ErrorPath: | |||
| 342 | - | return "error.html", true | |||
| 343 | - | case WebRTCPath: | |||
| 344 | - | return "webrtc.html", true | |||
| 345 | default: | 541 | default: | ||
| 346 | - | return "", false | 542 | + | return "", "", false |
| 347 | } | 543 | } | ||
| 348 | } | 544 | } | ||
| 349 | 545 | ||||
| 546 | + | func shouldAddCORSHeaders(requestPath string) bool { | |||
| 547 | + | requestPath = strings.TrimSpace(requestPath) | |||
| 548 | + | return strings.HasSuffix(requestPath, ".wasm") | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func responseHeadersForPath(requestPath, contentType string) map[string]string { | |||
| 552 | + | if !shouldAddCORSHeaders(requestPath) { | |||
| 553 | + | return nil | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | headers := map[string]string{ | |||
| 557 | + | "Access-Control-Allow-Origin": "*", | |||
| 558 | + | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |||
| 559 | + | "Access-Control-Allow-Headers": "Content-Type", | |||
| 560 | + | "Access-Control-Max-Age": "86400", | |||
| 561 | + | } | |||
| 562 | + | if contentType != "" { | |||
| 563 | + | headers["Content-Type"] = contentType | |||
| 564 | + | } | |||
| 565 | + | return headers | |||
| 566 | + | } | |||
| 567 | + | ||||
| 350 | // sendResponse sends the response back to WebKit. | 568 | // sendResponse sends the response back to WebKit. | ||
| 351 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | 569 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | ||
| 352 | if response == nil { | 570 | if response == nil { | ||
| @@ -378,14 +596,23 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||||
| 378 | schemeResp.SetContentType(contentType) | 596 | schemeResp.SetContentType(contentType) | ||
| 379 | schemeResp.SetStatus(uint(response.StatusCode), nil) | 597 | schemeResp.SetStatus(uint(response.StatusCode), nil) | ||
| 380 | 598 | ||||
| 381 | - | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | 599 | + | // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset. |
| 382 | - | // We only add CORS headers for our internal API endpoints. | 600 | + | // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers. |
| 383 | - | if strings.HasPrefix(req.GetPath(), "/api/") { | 601 | + | var headers map[string]string |
| 602 | + | if !response.SuppressDefaultHeaders { | |||
| 603 | + | headers = responseHeadersForPath(req.GetPath(), contentType) | |||
| 604 | + | } | |||
| 605 | + | for name, value := range response.Headers { | |||
| 606 | + | if headers == nil { | |||
| 607 | + | headers = map[string]string{} | |||
| 608 | + | } | |||
| 609 | + | headers[name] = value | |||
| 610 | + | } | |||
| 611 | + | if len(headers) > 0 { | |||
| 384 | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | 612 | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | ||
| 385 | - | hdrs.Append("Access-Control-Allow-Origin", "*") | 613 | + | for name, value := range headers { |
| 386 | - | hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | 614 | + | hdrs.Append(name, value) |
| 387 | - | hdrs.Append("Access-Control-Allow-Headers", "Content-Type") | 615 | + | } |
| 388 | - | hdrs.Append("Access-Control-Max-Age", "86400") | |||
| 389 | schemeResp.SetHttpHeaders(hdrs) | 616 | schemeResp.SetHttpHeaders(hdrs) | ||
| 390 | } | 617 | } | ||
| 391 | 618 | ||||
M internal/infrastructure/webkit/scheme_handler_test.go +216 -19
| @@ -2,7 +2,9 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "net/http" | |||
| 5 | "net/url" | 6 | "net/url" | ||
| 7 | + | "strings" | |||
| 6 | "testing" | 8 | "testing" | ||
| 7 | 9 | ||||
| 8 | "github.com/bnema/dumber/assets" | 10 | "github.com/bnema/dumber/assets" | ||
| @@ -11,8 +13,8 @@ import ( | |||||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | func TestCrashOriginalURI(t *testing.T) { | 15 | func TestCrashOriginalURI(t *testing.T) { | ||
| 14 | - | assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1")) | 16 | + | assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1")) |
| 15 | - | assert.Empty(t, crashOriginalURI("dumb://home/crash")) | 17 | + | assert.Empty(t, crashOriginalURI("dumb://history/crash")) |
| 16 | assert.Empty(t, crashOriginalURI("%%%")) | 18 | assert.Empty(t, crashOriginalURI("%%%")) | ||
| 17 | } | 19 | } | ||
| 18 | 20 | ||||
| @@ -37,8 +39,8 @@ func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) { | |||||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
| 39 | func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) { | 41 | func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) { | ||
| 40 | - | body := buildCrashPageHTML("dumb://home") | 42 | + | body := buildCrashPageHTML("dumb://history") |
| 41 | - | assert.Contains(t, body, `data-target="dumb://home"`) | 43 | + | assert.Contains(t, body, `data-target="dumb://history"`) |
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| 44 | func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | 46 | func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | ||
| @@ -48,6 +50,41 @@ func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) { | |||||
| 48 | assert.Contains(t, body, "</script><script>alert(1)</script>") | 50 | assert.Contains(t, body, "</script><script>alert(1)</script>") | ||
| 49 | } | 51 | } | ||
| 50 | 52 | ||||
| 53 | + | func TestIsTrustedSystemviewURL(t *testing.T) { | |||
| 54 | + | t.Parallel() | |||
| 55 | + | ||||
| 56 | + | for _, raw := range []string{ | |||
| 57 | + | "dumb://history", | |||
| 58 | + | "dumb://favorites", | |||
| 59 | + | "dumb:config", | |||
| 60 | + | } { | |||
| 61 | + | require.True(t, isTrustedSystemviewURL(raw), raw) | |||
| 62 | + | } | |||
| 63 | + | for _, raw := range []string{ | |||
| 64 | + | "", | |||
| 65 | + | "https://dumber.invalid/history", | |||
| 66 | + | "dumb://api/favicon", | |||
| 67 | + | "dumb://evil/history", | |||
| 68 | + | } { | |||
| 69 | + | require.False(t, isTrustedSystemviewURL(raw), raw) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) { | |||
| 74 | + | t.Parallel() | |||
| 75 | + | ||||
| 76 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"})) | |||
| 77 | + | require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""})) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) { | |||
| 81 | + | t.Parallel() | |||
| 82 | + | ||||
| 83 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"})) | |||
| 84 | + | require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"})) | |||
| 85 | + | require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"})) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 51 | func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | 88 | func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | ||
| 52 | handler := NewDumbSchemeHandler(context.Background()) | 89 | handler := NewDumbSchemeHandler(context.Background()) | ||
| 53 | require.NotNil(t, handler) | 90 | require.NotNil(t, handler) | ||
| @@ -59,7 +96,7 @@ func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) { | |||||
| 59 | require.NotNil(t, crashHandler) | 96 | require.NotNil(t, crashHandler) | ||
| 60 | 97 | ||||
| 61 | resp := crashHandler.Handle(&SchemeRequest{ | 98 | resp := crashHandler.Handle(&SchemeRequest{ | ||
| 62 | - | URI: "dumb://home/crash?url=https%3A%2F%2Fexample.com", | 99 | + | URI: "dumb://history/crash?url=https%3A%2F%2Fexample.com", |
| 63 | Path: "/crash", | 100 | Path: "/crash", | ||
| 64 | Method: "GET", | 101 | Method: "GET", | ||
| 65 | Scheme: "dumb", | 102 | Scheme: "dumb", | ||
| @@ -81,7 +118,7 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||||
| 81 | require.NotNil(t, crashHandler) | 118 | require.NotNil(t, crashHandler) | ||
| 82 | 119 | ||||
| 83 | resp := crashHandler.Handle(&SchemeRequest{ | 120 | resp := crashHandler.Handle(&SchemeRequest{ | ||
| 84 | - | URI: "dumb://home/crash?url=javascript%3Aalert(1)", | 121 | + | URI: "dumb://history/crash?url=javascript%3Aalert(1)", |
| 85 | Path: "/crash", | 122 | Path: "/crash", | ||
| 86 | Method: "GET", | 123 | Method: "GET", | ||
| 87 | Scheme: "dumb", | 124 | Scheme: "dumb", | ||
| @@ -90,41 +127,201 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) { | |||||
| 90 | assert.Contains(t, string(resp.Data), `data-target=""`) | 127 | assert.Contains(t, string(resp.Data), `data-target=""`) | ||
| 91 | } | 128 | } | ||
| 92 | 129 | ||||
| 93 | - | func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) { | 130 | + | func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) { |
| 94 | h := NewDumbSchemeHandler(context.Background()) | 131 | h := NewDumbSchemeHandler(context.Background()) | ||
| 95 | h.SetAssets(assets.WebUIAssets) | 132 | h.SetAssets(assets.WebUIAssets) | ||
| 96 | 133 | ||||
| 97 | - | u, err := url.Parse("dumb://webrtc/") | 134 | + | u, err := url.Parse("dumb:config") |
| 98 | require.NoError(t, err) | 135 | require.NoError(t, err) | ||
| 99 | 136 | ||||
| 100 | resp := h.handleAsset(u) | 137 | resp := h.handleAsset(u) | ||
| 101 | require.NotNil(t, resp) | 138 | require.NotNil(t, resp) | ||
| 102 | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | 139 | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | ||
| 103 | - | assert.Contains(t, string(resp.Data), "webrtc.min.js") | 140 | + | assert.Contains(t, string(resp.Data), "systemviews.wasm") |
| 104 | } | 141 | } | ||
| 105 | 142 | ||||
| 106 | - | func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) { | 143 | + | func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) { |
| 107 | h := NewDumbSchemeHandler(context.Background()) | 144 | h := NewDumbSchemeHandler(context.Background()) | ||
| 108 | h.SetAssets(assets.WebUIAssets) | 145 | h.SetAssets(assets.WebUIAssets) | ||
| 109 | 146 | ||||
| 110 | - | u, err := url.Parse("dumb:webrtc") | 147 | + | u, err := url.Parse("dumb://history/systemviews.css") |
| 111 | require.NoError(t, err) | 148 | require.NoError(t, err) | ||
| 112 | 149 | ||||
| 113 | resp := h.handleAsset(u) | 150 | resp := h.handleAsset(u) | ||
| 114 | require.NotNil(t, resp) | 151 | require.NotNil(t, resp) | ||
| 115 | - | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | 152 | + | // Charset suffixes are acceptable; assert the MIME type contract only. |
| 116 | - | assert.Contains(t, string(resp.Data), "webrtc.min.js") | 153 | + | assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0]) |
| 154 | + | assert.Contains(t, string(resp.Data), ".sv-app") | |||
| 117 | } | 155 | } | ||
| 118 | 156 | ||||
| 119 | - | func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) { | 157 | + | func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { |
| 120 | h := NewDumbSchemeHandler(context.Background()) | 158 | h := NewDumbSchemeHandler(context.Background()) | ||
| 121 | h.SetAssets(assets.WebUIAssets) | 159 | h.SetAssets(assets.WebUIAssets) | ||
| 122 | 160 | ||||
| 123 | - | u, err := url.Parse("dumb:config") | 161 | + | tests := []string{ |
| 162 | + | "dumb://history", | |||
| 163 | + | "dumb:history", | |||
| 164 | + | "dumb://favorites", | |||
| 165 | + | "dumb:favorites", | |||
| 166 | + | "dumb://config", | |||
| 167 | + | "dumb:config", | |||
| 168 | + | "dumb://error", | |||
| 169 | + | "dumb:error", | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | for _, raw := range tests { | |||
| 173 | + | t.Run(raw, func(t *testing.T) { | |||
| 174 | + | u, err := url.Parse(raw) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | ||||
| 177 | + | resp := h.handleAsset(u) | |||
| 178 | + | require.NotNil(t, resp) | |||
| 179 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 180 | + | assert.Contains(t, string(resp.Data), "systemviews.wasm") | |||
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||
| 186 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 187 | + | h.SetConfigPayloadBuilders( | |||
| 188 | + | func() ([]byte, error) { return []byte(`{"current":true}`), nil }, | |||
| 189 | + | func() ([]byte, error) { return []byte(`{"default":true}`), nil }, | |||
| 190 | + | ) | |||
| 191 | + | ||||
| 192 | + | h.mu.RLock() | |||
| 193 | + | currentHandler := h.handlers["/api/config"] | |||
| 194 | + | defaultHandler := h.handlers["/api/config/default"] | |||
| 195 | + | h.mu.RUnlock() | |||
| 196 | + | require.NotNil(t, currentHandler) | |||
| 197 | + | require.NotNil(t, defaultHandler) | |||
| 198 | + | ||||
| 199 | + | currentResp := currentHandler.Handle(&SchemeRequest{ | |||
| 200 | + | URI: "dumb://config/api/config", | |||
| 201 | + | Path: "/api/config", | |||
| 202 | + | Method: http.MethodGet, | |||
| 203 | + | Scheme: "dumb", | |||
| 204 | + | Referer: "dumb://config", | |||
| 205 | + | }) | |||
| 206 | + | require.NotNil(t, currentResp) | |||
| 207 | + | assert.Equal(t, http.StatusOK, currentResp.StatusCode) | |||
| 208 | + | assert.Equal(t, "application/json", currentResp.ContentType) | |||
| 209 | + | assert.Equal(t, []byte(`{"current":true}`), currentResp.Data) | |||
| 210 | + | ||||
| 211 | + | defaultResp := defaultHandler.Handle(&SchemeRequest{ | |||
| 212 | + | URI: "dumb://config/api/config/default", | |||
| 213 | + | Path: "/api/config/default", | |||
| 214 | + | Method: http.MethodGet, | |||
| 215 | + | Scheme: "dumb", | |||
| 216 | + | Referer: "dumb://config", | |||
| 217 | + | }) | |||
| 218 | + | require.NotNil(t, defaultResp) | |||
| 219 | + | assert.Equal(t, http.StatusOK, defaultResp.StatusCode) | |||
| 220 | + | assert.Equal(t, "application/json", defaultResp.ContentType) | |||
| 221 | + | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestConfigHandlersRejectUntrustedRequests(t *testing.T) { | |||
| 225 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 226 | + | h.SetConfigPayloadBuilders( | |||
| 227 | + | func() ([]byte, error) { return []byte(`{"current":true}`), nil }, | |||
| 228 | + | func() ([]byte, error) { return []byte(`{"default":true}`), nil }, | |||
| 229 | + | ) | |||
| 230 | + | ||||
| 231 | + | h.mu.RLock() | |||
| 232 | + | currentHandler := h.handlers["/api/config"] | |||
| 233 | + | defaultHandler := h.handlers["/api/config/default"] | |||
| 234 | + | h.mu.RUnlock() | |||
| 235 | + | ||||
| 236 | + | for _, handler := range []PageHandler{currentHandler, defaultHandler} { | |||
| 237 | + | resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"}) | |||
| 238 | + | require.NotNil(t, resp) | |||
| 239 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |||
| 240 | + | assert.True(t, resp.SuppressDefaultHeaders) | |||
| 241 | + | assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin") | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestShouldAddCORSHeaders(t *testing.T) { | |||
| 246 | + | t.Parallel() | |||
| 247 | + | ||||
| 248 | + | tests := []struct { | |||
| 249 | + | name string | |||
| 250 | + | path string | |||
| 251 | + | want bool | |||
| 252 | + | }{ | |||
| 253 | + | {name: "private api route", path: "/api/config", want: false}, | |||
| 254 | + | {name: "wasm asset", path: "/systemviews.wasm", want: true}, | |||
| 255 | + | {name: "html shell", path: "/", want: false}, | |||
| 256 | + | {name: "js asset", path: "/wasm_exec.js", want: false}, | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | for _, tt := range tests { | |||
| 260 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 261 | + | t.Parallel() | |||
| 262 | + | assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path)) | |||
| 263 | + | }) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||
| 268 | + | t.Parallel() | |||
| 269 | + | ||||
| 270 | + | headers := responseHeadersForPath("/systemviews.wasm", "application/wasm") | |||
| 271 | + | assert.Equal(t, "application/wasm", headers["Content-Type"]) | |||
| 272 | + | assert.Equal(t, "*", headers["Access-Control-Allow-Origin"]) | |||
| 273 | + | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | |||
| 277 | + | t.Parallel() | |||
| 278 | + | ||||
| 279 | + | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |||
| 124 | require.NoError(t, err) | 280 | require.NoError(t, err) | ||
| 281 | + | require.GreaterOrEqual(t, len(data), 4) | |||
| 282 | + | assert.Equal(t, "\x00asm", string(data[:4])) | |||
| 283 | + | } | |||
| 125 | 284 | ||||
| 126 | - | resp := h.handleAsset(u) | 285 | + | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { |
| 127 | - | require.NotNil(t, resp) | 286 | + | h := NewDumbSchemeHandler(context.Background()) |
| 128 | - | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | 287 | + | h.SetAssets(assets.WebUIAssets) |
| 129 | - | assert.Contains(t, string(resp.Data), "config") | 288 | + | |
| 289 | + | for _, raw := range []string{ | |||
| 290 | + | "dumb://history/../logo.svg", | |||
| 291 | + | "dumb://history/nested/../../logo.svg", | |||
| 292 | + | } { | |||
| 293 | + | t.Run(raw, func(t *testing.T) { | |||
| 294 | + | u, err := url.Parse(raw) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | assert.Nil(t, h.handleAsset(u)) | |||
| 297 | + | }) | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) { | |||
| 302 | + | t.Parallel() | |||
| 303 | + | ||||
| 304 | + | fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css") | |||
| 305 | + | require.True(t, ok) | |||
| 306 | + | assert.Equal(t, "systemviews/systemviews.css", fullPath) | |||
| 307 | + | assert.Equal(t, "systemviews.css", relPath) | |||
| 308 | + | ||||
| 309 | + | invalid := []struct { | |||
| 310 | + | name string | |||
| 311 | + | assetDir string | |||
| 312 | + | relPath string | |||
| 313 | + | }{ | |||
| 314 | + | {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"}, | |||
| 315 | + | {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"}, | |||
| 316 | + | {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"}, | |||
| 317 | + | {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"}, | |||
| 318 | + | {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"}, | |||
| 319 | + | } | |||
| 320 | + | for _, tt := range invalid { | |||
| 321 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 322 | + | t.Parallel() | |||
| 323 | + | _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath) | |||
| 324 | + | assert.False(t, ok) | |||
| 325 | + | }) | |||
| 326 | + | } | |||
| 130 | } | 327 | } | ||
M internal/infrastructure/webutil/mime.go +5 -1
| @@ -9,7 +9,11 @@ import ( | |||||
| 9 | // GetMimeType determines the MIME type for a given file path. | 9 | // GetMimeType determines the MIME type for a given file path. | ||
| 10 | // It tries the standard library first, then falls back to common web asset types. | 10 | // It tries the standard library first, then falls back to common web asset types. | ||
| 11 | func GetMimeType(filename string) string { | 11 | func GetMimeType(filename string) string { | ||
| 12 | - | ext := strings.ToLower(filepath.Ext(filename)) | 12 | + | lowerName := strings.ToLower(filename) |
| 13 | + | ext := filepath.Ext(lowerName) | |||
| 14 | + | if ext == ".br" { | |||
| 15 | + | return GetMimeType(strings.TrimSuffix(lowerName, ext)) | |||
| 16 | + | } | |||
| 13 | 17 | ||||
| 14 | mt := mime.TypeByExtension(ext) | 18 | mt := mime.TypeByExtension(ext) | ||
| 15 | if mt != "" { | 19 | if mt != "" { | ||
A internal/infrastructure/webutil/mime_test.go +31 -0
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package webutil | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) { | |||
| 6 | + | t.Parallel() | |||
| 7 | + | ||||
| 8 | + | tests := []struct { | |||
| 9 | + | name string | |||
| 10 | + | filename string | |||
| 11 | + | want string | |||
| 12 | + | }{ | |||
| 13 | + | {name: "path input", filename: "assets/systemviews/systemviews.wasm", want: "application/wasm"}, | |||
| 14 | + | {name: "uppercase extension", filename: "systemviews.WASM", want: "application/wasm"}, | |||
| 15 | + | {name: "brotli compressed wasm", filename: "systemviews.wasm.br", want: "application/wasm"}, | |||
| 16 | + | {name: "uppercase brotli compressed wasm", filename: "SYSTEMVIEWS.WASM.BR", want: "application/wasm"}, | |||
| 17 | + | {name: "plain brotli", filename: "data.br", want: "text/plain"}, | |||
| 18 | + | {name: "js brotli", filename: "app.js.br", want: "text/javascript; charset=utf-8"}, | |||
| 19 | + | {name: "tar brotli", filename: "archive.tar.br", want: "application/x-tar"}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | for _, tt := range tests { | |||
| 23 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 24 | + | t.Parallel() | |||
| 25 | + | ||||
| 26 | + | if got := GetMimeType(tt.filename); got != tt.want { | |||
| 27 | + | t.Fatalf("GetMimeType(%s) = %q, want %q", tt.filename, got, tt.want) | |||
| 28 | + | } | |||
| 29 | + | }) | |||
| 30 | + | } | |||
| 31 | + | } | |||
M internal/ui/app.go +5 -1
| @@ -204,6 +204,10 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 204 | glib.IdleAdd(&cb, 0) | 204 | glib.IdleAdd(&cb, 0) | ||
| 205 | }) | 205 | }) | ||
| 206 | } | 206 | } | ||
| 207 | + | if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok { | |||
| 208 | + | faviconSetter.SetSystemviewFaviconService(deps.FaviconService) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 207 | // Register message handlers through the engine. | 211 | // Register message handlers through the engine. | ||
| 208 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | 212 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | ||
| 209 | HistoryUC: deps.HistoryUC, | 213 | HistoryUC: deps.HistoryUC, | ||
| @@ -795,7 +799,7 @@ func (a *App) initialWindowURL() string { | |||||
| 795 | if a.deps != nil && a.deps.InitialURL != "" { | 799 | if a.deps != nil && a.deps.InitialURL != "" { | ||
| 796 | return a.deps.InitialURL | 800 | return a.deps.InitialURL | ||
| 797 | } | 801 | } | ||
| 798 | - | return "dumb://home" | 802 | + | return "dumb://history" |
| 799 | } | 803 | } | ||
| 800 | 804 | ||||
| 801 | func (a *App) initLayoutInfrastructure() { | 805 | func (a *App) initLayoutInfrastructure() { | ||
M internal/ui/app_clipboard_test.go +3 -2
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | 9 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -28,7 +29,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) | |||||
| 28 | Config: &config.Config{}, | 29 | Config: &config.Config{}, | ||
| 29 | Engine: engine, | 30 | Engine: engine, | ||
| 30 | HandlerDeps: port.HandlerDeps{ | 31 | HandlerDeps: port.HandlerDeps{ | ||
| 31 | - | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | 32 | + | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, |
| 32 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | 33 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | ||
| 33 | }, | 34 | }, | ||
| 34 | Clipboard: nil, | 35 | Clipboard: nil, | ||
| @@ -57,7 +58,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |||||
| 57 | Config: &config.Config{}, | 58 | Config: &config.Config{}, | ||
| 58 | Engine: engine, | 59 | Engine: engine, | ||
| 59 | HandlerDeps: port.HandlerDeps{ | 60 | HandlerDeps: port.HandlerDeps{ | ||
| 60 | - | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | 61 | + | SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil }, |
| 61 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | 62 | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | ||
| 62 | }, | 63 | }, | ||
| 63 | Clipboard: clipboard, | 64 | Clipboard: clipboard, | ||
M internal/ui/coordinator/content/appearance.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | const ( | 15 | const ( | ||
| 16 | aboutBlankURI = "about:blank" | 16 | aboutBlankURI = "about:blank" | ||
| 17 | - | crashPageURI = "dumb://home/crash" | 17 | + | crashPageURI = "dumb://history/crash" |
| 18 | logURLMaxLen = 80 | 18 | logURLMaxLen = 80 | ||
| 19 | oauthParentRefreshDebounce = 200 * time.Millisecond | 19 | oauthParentRefreshDebounce = 200 * time.Millisecond | ||
| 20 | 20 | ||||
M internal/ui/coordinator/content/callbacks.go +2 -2
| @@ -12,8 +12,8 @@ import ( | |||||
| 12 | "github.com/bnema/puregotk/v4/gdk" | 12 | "github.com/bnema/puregotk/v4/gdk" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | // internalSchemePath is the path used in dumb:// URIs (replaces webkit.HomePath). | 15 | + | // internalSchemePath is the host used in dumb:// crash-page URIs. |
| 16 | - | const internalSchemePath = "home" | 16 | + | const internalSchemePath = "history" |
| 17 | 17 | ||||
| 18 | func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool { | 18 | func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool { | ||
| 19 | switch reason { | 19 | switch reason { | ||
M internal/ui/coordinator/content/content_termination_test.go +4 -4
| @@ -15,13 +15,13 @@ func TestShouldRenderCrashPage(t *testing.T) { | |||||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | func TestExtractOriginalURIFromCrashPage(t *testing.T) { | 17 | func TestExtractOriginalURIFromCrashPage(t *testing.T) { | ||
| 18 | - | assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1")) | 18 | + | assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1")) |
| 19 | - | assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash")) | 19 | + | assert.Empty(t, extractOriginalURIFromCrashPage("dumb://history/crash")) |
| 20 | assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com")) | 20 | assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com")) | ||
| 21 | assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad")) | 21 | assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad")) | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | func TestBuildCrashPageURI(t *testing.T) { | 24 | func TestBuildCrashPageURI(t *testing.T) { | ||
| 25 | - | assert.Equal(t, "dumb://home/crash", buildCrashPageURI("")) | 25 | + | assert.Equal(t, "dumb://history/crash", buildCrashPageURI("")) |
| 26 | - | assert.Equal(t, "dumb://home/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2")) | 26 | + | assert.Equal(t, "dumb://history/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2")) |
| 27 | } | 27 | } | ||
M internal/ui/coordinator/workspace.go +128 -1
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "net/url" | |||
| 8 | + | "strings" | |||
| 7 | 9 | ||||
| 8 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -171,6 +173,15 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com | |||||
| 171 | 173 | ||||
| 172 | // Split splits the active pane in the given direction. | 174 | // Split splits the active pane in the given direction. | ||
| 173 | func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error { | 175 | func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error { | ||
| 176 | + | return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(c.newPaneURL)) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // SplitWithURL splits the active pane in the given direction and loads initialURL. | |||
| 180 | + | func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error { | |||
| 181 | + | return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(initialURL)) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error { | |||
| 174 | log := logging.FromContext(ctx) | 185 | log := logging.FromContext(ctx) | ||
| 175 | 186 | ||||
| 176 | splitCtx, ok := c.prepareSplit(ctx, direction) | 187 | splitCtx, ok := c.prepareSplit(ctx, direction) | ||
| @@ -182,7 +193,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli | |||||
| 182 | Workspace: splitCtx.ws, | 193 | Workspace: splitCtx.ws, | ||
| 183 | TargetPane: splitCtx.activePane, | 194 | TargetPane: splitCtx.activePane, | ||
| 184 | Direction: direction, | 195 | Direction: direction, | ||
| 185 | - | InitialURL: domainurl.Normalize(c.newPaneURL), | 196 | + | InitialURL: initialURL, |
| 186 | }) | 197 | }) | ||
| 187 | if err != nil { | 198 | if err != nil { | ||
| 188 | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane") | 199 | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane") | ||
| @@ -212,6 +223,122 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli | |||||
| 212 | return nil | 223 | return nil | ||
| 213 | } | 224 | } | ||
| 214 | 225 | ||||
| 226 | + | // ToggleSystemViewRight focuses an existing system view pane, closes it if it is | |||
| 227 | + | // already active, or opens it in a right split when no matching pane exists. | |||
| 228 | + | func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, targetURL string) error { | |||
| 229 | + | log := logging.FromContext(ctx) | |||
| 230 | + | targetURL = domainurl.Normalize(targetURL) | |||
| 231 | + | if targetURL == "" { | |||
| 232 | + | return fmt.Errorf("system view URL is required") | |||
| 233 | + | } | |||
| 234 | + | if c.getActiveWS == nil { | |||
| 235 | + | log.Warn().Str("url", targetURL).Msg("active workspace provider not configured") | |||
| 236 | + | return fmt.Errorf("system view toggle: active workspace provider not configured") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | ws, wsView := c.getActiveWS() | |||
| 240 | + | if ws == nil { | |||
| 241 | + | log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle") | |||
| 242 | + | return fmt.Errorf("system view toggle: no active workspace") | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | activePane := ws.ActivePane() | |||
| 246 | + | if paneMatchesURL(activePane, targetURL) { | |||
| 247 | + | if ws.PaneCount() <= 1 { | |||
| 248 | + | log.Debug().Str("url", targetURL).Msg("system view is active in the last pane; leaving it open") | |||
| 249 | + | return nil | |||
| 250 | + | } | |||
| 251 | + | log.Debug().Str("url", targetURL).Msg("system view already active; closing pane") | |||
| 252 | + | return c.ClosePane(ctx) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | if existing := findPaneByURL(ws, targetURL); existing != nil { | |||
| 256 | + | log.Debug().Str("url", targetURL).Str("pane_id", string(existing.ID)).Msg("focusing existing system view pane") | |||
| 257 | + | c.focusExistingPane(ctx, ws, wsView, existing.ID) | |||
| 258 | + | return nil | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | log.Debug().Str("url", targetURL).Msg("opening system view in right split") | |||
| 262 | + | return c.SplitWithURL(ctx, usecase.SplitRight, targetURL) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func paneMatchesURL(node *entity.PaneNode, targetURL string) bool { | |||
| 266 | + | return node != nil && node.Pane != nil && comparablePaneURL(node.Pane.URI) == comparablePaneURL(targetURL) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func findPaneByURL(ws *entity.Workspace, targetURL string) *entity.Pane { | |||
| 270 | + | if ws == nil { | |||
| 271 | + | return nil | |||
| 272 | + | } | |||
| 273 | + | targetURL = comparablePaneURL(targetURL) | |||
| 274 | + | for _, pane := range ws.AllPanes() { | |||
| 275 | + | if pane != nil && comparablePaneURL(pane.URI) == targetURL { | |||
| 276 | + | return pane | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | return nil | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func comparablePaneURL(raw string) string { | |||
| 283 | + | normalized := domainurl.Normalize(raw) | |||
| 284 | + | parsed, err := url.Parse(normalized) | |||
| 285 | + | if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") { | |||
| 286 | + | return normalized | |||
| 287 | + | } | |||
| 288 | + | page := parsed.Host | |||
| 289 | + | if page == "" { | |||
| 290 | + | page = parsed.Opaque | |||
| 291 | + | } | |||
| 292 | + | if page == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { | |||
| 293 | + | return normalized | |||
| 294 | + | } | |||
| 295 | + | return "dumb:" + page | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (c *WorkspaceCoordinator) focusExistingPane( | |||
| 299 | + | ctx context.Context, | |||
| 300 | + | ws *entity.Workspace, | |||
| 301 | + | wsView *component.WorkspaceView, | |||
| 302 | + | paneID entity.PaneID, | |||
| 303 | + | ) { | |||
| 304 | + | oldActivePaneID := ws.ActivePaneID | |||
| 305 | + | ws.ActivePaneID = paneID | |||
| 306 | + | paneNode := ws.FindPane(paneID) | |||
| 307 | + | if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { | |||
| 308 | + | setActiveStackIndexForChild(paneNode.Parent, paneNode) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | if wsView != nil { | |||
| 312 | + | wsView.CancelAllPendingHovers() | |||
| 313 | + | wsView.SuppressHover(component.KeyboardFocusSuppressDuration) | |||
| 314 | + | if oldActivePaneID != "" && oldActivePaneID != paneID { | |||
| 315 | + | wsView.DeactivatePane(oldActivePaneID) | |||
| 316 | + | } | |||
| 317 | + | if err := wsView.SetActivePaneID(paneID); err != nil { | |||
| 318 | + | logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to focus existing pane in view") | |||
| 319 | + | } else { | |||
| 320 | + | wsView.FocusPane(paneID) | |||
| 321 | + | } | |||
| 322 | + | if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked { | |||
| 323 | + | c.syncStackedViewActive(ctx, wsView, paneNode) | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | c.notifyStateChanged() | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func setActiveStackIndexForChild(parent, child *entity.PaneNode) { | |||
| 331 | + | if parent == nil || child == nil { | |||
| 332 | + | return | |||
| 333 | + | } | |||
| 334 | + | for i, candidate := range parent.Children { | |||
| 335 | + | if candidate == child { | |||
| 336 | + | parent.ActiveStackIndex = i | |||
| 337 | + | return | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | ||||
| 215 | func (c *WorkspaceCoordinator) prepareSplit( | 342 | func (c *WorkspaceCoordinator) prepareSplit( | ||
| 216 | ctx context.Context, | 343 | ctx context.Context, | ||
| 217 | direction usecase.SplitDirection, | 344 | direction usecase.SplitDirection, | ||
A internal/ui/coordinator/workspace_systemview_test.go +126 -0
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestWorkspaceCoordinator_ToggleSystemViewRightSplitsWithTargetURL(t *testing.T) { | |||
| 15 | + | ctx := context.Background() | |||
| 16 | + | ids := []string{"pane-2", "split-1"} | |||
| 17 | + | idx := 0 | |||
| 18 | + | uc := usecase.NewManagePanesUseCase(func() string { | |||
| 19 | + | id := ids[idx] | |||
| 20 | + | idx++ | |||
| 21 | + | return id | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | initialPane := entity.NewPane("pane-1") | |||
| 25 | + | initialPane.URI = "https://example.com" | |||
| 26 | + | ws := entity.NewWorkspace("ws-1", initialPane) | |||
| 27 | + | ||||
| 28 | + | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |||
| 29 | + | PanesUC: uc, | |||
| 30 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |||
| 31 | + | return ws, nil | |||
| 32 | + | }, | |||
| 33 | + | }) | |||
| 34 | + | ||||
| 35 | + | err := coord.ToggleSystemViewRight(ctx, "dumb://history") | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | ||||
| 38 | + | require.Equal(t, 2, ws.PaneCount()) | |||
| 39 | + | active := ws.ActivePane() | |||
| 40 | + | require.NotNil(t, active) | |||
| 41 | + | require.NotNil(t, active.Pane) | |||
| 42 | + | assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID) | |||
| 43 | + | assert.Equal(t, "dumb://history", active.Pane.URI) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testing.T) { | |||
| 47 | + | ctx := context.Background() | |||
| 48 | + | left := testLeafNode("pane-1") | |||
| 49 | + | left.Pane.URI = "https://example.com" | |||
| 50 | + | right := testLeafNode("pane-2") | |||
| 51 | + | right.Pane.URI = "dumb://history" | |||
| 52 | + | root := testSplitNode("split-1", left, right) | |||
| 53 | + | ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID} | |||
| 54 | + | ||||
| 55 | + | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |||
| 56 | + | PanesUC: usecase.NewManagePanesUseCase(func() string { | |||
| 57 | + | t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane") | |||
| 58 | + | return "unused" | |||
| 59 | + | }), | |||
| 60 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |||
| 61 | + | return ws, nil | |||
| 62 | + | }, | |||
| 63 | + | }) | |||
| 64 | + | ||||
| 65 | + | err := coord.ToggleSystemViewRight(ctx, "dumb://history") | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | ||||
| 68 | + | assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID) | |||
| 69 | + | assert.Equal(t, 2, ws.PaneCount()) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T) { | |||
| 73 | + | ctx := context.Background() | |||
| 74 | + | uc := usecase.NewManagePanesUseCase(func() string { return "unused" }) | |||
| 75 | + | left := testLeafNode("pane-1") | |||
| 76 | + | left.Pane.URI = "https://example.com" | |||
| 77 | + | right := testLeafNode("pane-2") | |||
| 78 | + | right.Pane.URI = "dumb://history" | |||
| 79 | + | root := testSplitNode("split-1", left, right) | |||
| 80 | + | ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: right.Pane.ID} | |||
| 81 | + | ||||
| 82 | + | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |||
| 83 | + | PanesUC: uc, | |||
| 84 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |||
| 85 | + | return ws, nil | |||
| 86 | + | }, | |||
| 87 | + | }) | |||
| 88 | + | ||||
| 89 | + | err := coord.ToggleSystemViewRight(ctx, "dumb://history") | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | ||||
| 92 | + | assert.Equal(t, 1, ws.PaneCount()) | |||
| 93 | + | assert.Equal(t, entity.PaneID("pane-1"), ws.ActivePaneID) | |||
| 94 | + | active := ws.ActivePane() | |||
| 95 | + | require.NotNil(t, active) | |||
| 96 | + | assert.Equal(t, "https://example.com", active.Pane.URI) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestWorkspaceCoordinator_ToggleSystemViewRightUpdatesStackIndexForExistingPane(t *testing.T) { | |||
| 100 | + | ctx := context.Background() | |||
| 101 | + | first := testLeafNode("pane-1") | |||
| 102 | + | first.Pane.URI = "https://example.com" | |||
| 103 | + | second := testLeafNode("pane-2") | |||
| 104 | + | second.Pane.URI = "dumb://history" | |||
| 105 | + | stack := &entity.PaneNode{ | |||
| 106 | + | ID: "stack-1", | |||
| 107 | + | IsStacked: true, | |||
| 108 | + | ActiveStackIndex: 0, | |||
| 109 | + | Children: []*entity.PaneNode{first, second}, | |||
| 110 | + | } | |||
| 111 | + | first.Parent = stack | |||
| 112 | + | second.Parent = stack | |||
| 113 | + | ws := &entity.Workspace{ID: "ws-1", Root: stack, ActivePaneID: first.Pane.ID} | |||
| 114 | + | ||||
| 115 | + | coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{ | |||
| 116 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |||
| 117 | + | return ws, nil | |||
| 118 | + | }, | |||
| 119 | + | }) | |||
| 120 | + | ||||
| 121 | + | err := coord.ToggleSystemViewRight(ctx, "dumb://history") | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | ||||
| 124 | + | assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID) | |||
| 125 | + | assert.Equal(t, 1, stack.ActiveStackIndex) | |||
| 126 | + | } | |||
M internal/ui/dispatcher/keyboard.go +15 -0
| @@ -14,6 +14,12 @@ import ( | |||||
| 14 | "github.com/bnema/puregotk/v4/glib" | 14 | "github.com/bnema/puregotk/v4/glib" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | const ( | |||
| 18 | + | historySystemViewURL = "dumb://history" | |||
| 19 | + | favoritesSystemViewURL = "dumb://favorites" | |||
| 20 | + | configSystemViewURL = "dumb://config" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 17 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | 23 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | ||
| 18 | type KeyboardDispatcher struct { | 24 | type KeyboardDispatcher struct { | ||
| 19 | tabCoord *coordinator.TabCoordinator | 25 | tabCoord *coordinator.TabCoordinator | ||
| @@ -225,6 +231,15 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||||
| 225 | } | 231 | } | ||
| 226 | return d.logNoop(ctx, "toggle floating pane action (no handler)") | 232 | return d.logNoop(ctx, "toggle floating pane action (no handler)") | ||
| 227 | }, | 233 | }, | ||
| 234 | + | input.ActionToggleHistorySystemView: func(ctx context.Context) error { | |||
| 235 | + | return d.wsCoord.ToggleSystemViewRight(ctx, historySystemViewURL) | |||
| 236 | + | }, | |||
| 237 | + | input.ActionToggleFavoritesSystemView: func(ctx context.Context) error { | |||
| 238 | + | return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL) | |||
| 239 | + | }, | |||
| 240 | + | input.ActionToggleConfigSystemView: func(ctx context.Context) error { | |||
| 241 | + | return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL) | |||
| 242 | + | }, | |||
| 228 | input.ActionToggleFullscreen: func(ctx context.Context) error { | 243 | input.ActionToggleFullscreen: func(ctx context.Context) error { | ||
| 229 | return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)") | 244 | return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)") | ||
| 230 | }, | 245 | }, | ||
M internal/ui/dispatcher/keyboard_test.go +34 -0
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/application/usecase" | 7 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 10 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 10 | "github.com/bnema/dumber/internal/ui/coordinator" | 11 | "github.com/bnema/dumber/internal/ui/coordinator" | ||
| 11 | "github.com/bnema/dumber/internal/ui/input" | 12 | "github.com/bnema/dumber/internal/ui/input" | ||
| 12 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| @@ -114,6 +115,39 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout | |||||
| 114 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | 115 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | ||
| 115 | } | 116 | } | ||
| 116 | 117 | ||||
| 118 | + | func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) { | |||
| 119 | + | ctx := context.Background() | |||
| 120 | + | ids := []string{"pane-2", "split-1"} | |||
| 121 | + | idx := 0 | |||
| 122 | + | panesUC := usecase.NewManagePanesUseCase(func() string { | |||
| 123 | + | id := ids[idx] | |||
| 124 | + | idx++ | |||
| 125 | + | return id | |||
| 126 | + | }) | |||
| 127 | + | ||||
| 128 | + | initialPane := entity.NewPane("pane-1") | |||
| 129 | + | initialPane.URI = "https://example.com" | |||
| 130 | + | ws := entity.NewWorkspace("ws-1", initialPane) | |||
| 131 | + | wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{ | |||
| 132 | + | PanesUC: panesUC, | |||
| 133 | + | GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { | |||
| 134 | + | return ws, nil | |||
| 135 | + | }, | |||
| 136 | + | }) | |||
| 137 | + | ||||
| 138 | + | d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" }) | |||
| 139 | + | ||||
| 140 | + | err := d.Dispatch(ctx, input.ActionToggleHistorySystemView) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | ||||
| 143 | + | require.Equal(t, 2, ws.PaneCount()) | |||
| 144 | + | active := ws.ActivePane() | |||
| 145 | + | require.NotNil(t, active) | |||
| 146 | + | require.NotNil(t, active.Pane) | |||
| 147 | + | assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID) | |||
| 148 | + | assert.Equal(t, "dumb://history", active.Pane.URI) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 117 | func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | 151 | func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | ||
| 118 | ctx := context.Background() | 152 | ctx := context.Background() | ||
| 119 | activePaneID := entity.PaneID("pane-1") | 153 | activePaneID := entity.PaneID("pane-1") | ||
M internal/ui/input/global_shortcuts.go +24 -17
| @@ -104,15 +104,7 @@ func NewGlobalShortcutHandler( | |||||
| 104 | Msg("registered global shortcut") | 104 | Msg("registered global shortcut") | ||
| 105 | 105 | ||||
| 106 | if workspace != nil { | 106 | if workspace != nil { | ||
| 107 | - | // Map action names to action constants for global 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 | - | } | |||
| 116 | 108 | ||||
| 117 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | 109 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | ||
| 118 | action, ok := actionMap[actionName] | 110 | action, ok := actionMap[actionName] | ||
| @@ -247,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||||
| 247 | return true | 239 | return true | ||
| 248 | } | 240 | } | ||
| 249 | 241 | ||||
| 242 | + | // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions. | |||
| 243 | + | func globalShortcutActionMap() map[string]Action { | |||
| 244 | + | return map[string]Action{ | |||
| 245 | + | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 246 | + | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 247 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 248 | + | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |||
| 249 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 250 | + | "consume-or-expel-right": ActionConsumeOrExpelRight, | |||
| 251 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 252 | + | "consume-or-expel-up": ActionConsumeOrExpelUp, | |||
| 253 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 254 | + | "consume-or-expel-down": ActionConsumeOrExpelDown, | |||
| 255 | + | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 256 | + | "toggle-history-systemview": ActionToggleHistorySystemView, | |||
| 257 | + | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |||
| 258 | + | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |||
| 259 | + | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 260 | + | "toggle-config-systemview": ActionToggleConfigSystemView, | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 250 | // ReloadShortcuts rebuilds global shortcuts from a new config. | 264 | // ReloadShortcuts rebuilds global shortcuts from a new config. | ||
| 251 | // It replaces the GTK shortcut controller to ensure stale shortcuts | 265 | // It replaces the GTK shortcut controller to ensure stale shortcuts | ||
| 252 | // are removed. | 266 | // are removed. | ||
| @@ -285,14 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||||
| 285 | 299 | ||||
| 286 | // Re-register config-driven shortcuts | 300 | // Re-register config-driven shortcuts | ||
| 287 | if workspace != nil { | 301 | if workspace != nil { | ||
| 288 | - | actionMap := map[string]Action{ | 302 | + | actionMap := globalShortcutActionMap() |
| 289 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 290 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 291 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 292 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 293 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 294 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 295 | - | } | |||
| 296 | 303 | ||||
| 297 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | 304 | for actionName, actionBinding := range workspace.Shortcuts.Actions { | ||
| 298 | action, ok := actionMap[actionName] | 305 | action, ok := actionMap[actionName] | ||
M internal/ui/input/shortcuts.go +38 -32
| @@ -179,14 +179,17 @@ const ( | |||||
| 179 | ActionZoomReset Action = "zoom_reset" | 179 | ActionZoomReset Action = "zoom_reset" | ||
| 180 | 180 | ||||
| 181 | // UI | 181 | // UI | ||
| 182 | - | ActionOpenOmnibox Action = "open_omnibox" | 182 | + | ActionOpenOmnibox Action = "open_omnibox" |
| 183 | - | ActionOpenFind Action = "open_find" | 183 | + | ActionOpenFind Action = "open_find" |
| 184 | - | ActionFindNext Action = "find_next" | 184 | + | ActionFindNext Action = "find_next" |
| 185 | - | ActionFindPrev Action = "find_prev" | 185 | + | ActionFindPrev Action = "find_prev" |
| 186 | - | ActionCloseFind Action = "close_find" | 186 | + | ActionCloseFind Action = "close_find" |
| 187 | - | ActionOpenDevTools Action = "open_devtools" | 187 | + | ActionOpenDevTools Action = "open_devtools" |
| 188 | - | ActionToggleFullscreen Action = "toggle_fullscreen" | 188 | + | ActionToggleFullscreen Action = "toggle_fullscreen" |
| 189 | - | ActionToggleFloatingPane Action = "toggle_floating_pane" | 189 | + | ActionToggleFloatingPane Action = "toggle_floating_pane" |
| 190 | + | ActionToggleHistorySystemView Action = "toggle_history_systemview" | |||
| 191 | + | ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview" | |||
| 192 | + | ActionToggleConfigSystemView Action = "toggle_config_systemview" | |||
| 190 | 193 | ||||
| 191 | // Clipboard | 194 | // Clipboard | ||
| 192 | ActionCopyURL Action = "copy_url" | 195 | ActionCopyURL Action = "copy_url" | ||
| @@ -359,29 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 359 | // r = rename tab | 362 | // r = rename tab | ||
| 360 | // This follows Zellij-style modal keyboard interface. | 363 | // This follows Zellij-style modal keyboard interface. | ||
| 361 | // | 364 | // | ||
| 362 | - | // However, these standard browser shortcuts ARE global: | 365 | + | // However, these standard browser shortcuts ARE global. |
| 363 | - | // Map action names to action constants | |||
| 364 | - | actionMap := map[string]Action{ | |||
| 365 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 366 | - | "toggle-floating-pane": ActionToggleFloatingPane, | |||
| 367 | - | "close_pane": ActionClosePane, | |||
| 368 | - | "close-pane": ActionClosePane, | |||
| 369 | - | "next_tab": ActionNextTab, | |||
| 370 | - | "next-tab": ActionNextTab, | |||
| 371 | - | "previous_tab": ActionPreviousTab, | |||
| 372 | - | "previous-tab": ActionPreviousTab, | |||
| 373 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 374 | - | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |||
| 375 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 376 | - | "consume-or-expel-right": ActionConsumeOrExpelRight, | |||
| 377 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 378 | - | "consume-or-expel-up": ActionConsumeOrExpelUp, | |||
| 379 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 380 | - | "consume-or-expel-down": ActionConsumeOrExpelDown, | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | for actionName, actionBinding := range cfg.Shortcuts.Actions { | 366 | for actionName, actionBinding := range cfg.Shortcuts.Actions { | ||
| 384 | - | action, ok := actionMap[actionName] | 367 | + | action, ok := configActionToAction[actionName] |
| 385 | if !ok { | 368 | if !ok { | ||
| 386 | continue | 369 | continue | ||
| 387 | } | 370 | } | ||
| @@ -580,29 +563,52 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||||
| 580 | } | 563 | } | ||
| 581 | 564 | ||||
| 582 | var configActionToAction = map[string]Action{ | 565 | var configActionToAction = map[string]Action{ | ||
| 583 | - | "toggle_floating_pane": ActionToggleFloatingPane, | 566 | + | "toggle_floating_pane": ActionToggleFloatingPane, |
| 584 | - | "toggle-floating-pane": ActionToggleFloatingPane, | 567 | + | "toggle-floating-pane": ActionToggleFloatingPane, |
| 568 | + | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 569 | + | "toggle-history-systemview": ActionToggleHistorySystemView, | |||
| 570 | + | "toggle_favorites_systemview": ActionToggleFavoritesSystemView, | |||
| 571 | + | "toggle-favorites-systemview": ActionToggleFavoritesSystemView, | |||
| 572 | + | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 573 | + | "toggle-config-systemview": ActionToggleConfigSystemView, | |||
| 585 | 574 | ||||
| 586 | // Tab actions | 575 | // Tab actions | ||
| 576 | + | "new_tab": ActionNewTab, | |||
| 587 | "new-tab": ActionNewTab, | 577 | "new-tab": ActionNewTab, | ||
| 578 | + | "close_tab": ActionCloseTab, | |||
| 588 | "close-tab": ActionCloseTab, | 579 | "close-tab": ActionCloseTab, | ||
| 580 | + | "next_tab": ActionNextTab, | |||
| 589 | "next-tab": ActionNextTab, | 581 | "next-tab": ActionNextTab, | ||
| 582 | + | "previous_tab": ActionPreviousTab, | |||
| 590 | "previous-tab": ActionPreviousTab, | 583 | "previous-tab": ActionPreviousTab, | ||
| 584 | + | "rename_tab": ActionRenameTab, | |||
| 591 | "rename-tab": ActionRenameTab, | 585 | "rename-tab": ActionRenameTab, | ||
| 592 | 586 | ||||
| 593 | // Pane actions | 587 | // Pane actions | ||
| 588 | + | "split_right": ActionSplitRight, | |||
| 594 | "split-right": ActionSplitRight, | 589 | "split-right": ActionSplitRight, | ||
| 590 | + | "split_left": ActionSplitLeft, | |||
| 595 | "split-left": ActionSplitLeft, | 591 | "split-left": ActionSplitLeft, | ||
| 592 | + | "split_up": ActionSplitUp, | |||
| 596 | "split-up": ActionSplitUp, | 593 | "split-up": ActionSplitUp, | ||
| 594 | + | "split_down": ActionSplitDown, | |||
| 597 | "split-down": ActionSplitDown, | 595 | "split-down": ActionSplitDown, | ||
| 596 | + | "close_pane": ActionClosePane, | |||
| 598 | "close-pane": ActionClosePane, | 597 | "close-pane": ActionClosePane, | ||
| 598 | + | "stack_pane": ActionStackPane, | |||
| 599 | "stack-pane": ActionStackPane, | 599 | "stack-pane": ActionStackPane, | ||
| 600 | + | "move_pane_to_tab": ActionMovePaneToTab, | |||
| 600 | "move-pane-to-tab": ActionMovePaneToTab, | 601 | "move-pane-to-tab": ActionMovePaneToTab, | ||
| 602 | + | "move_pane_to_next_tab": ActionMovePaneToNextTab, | |||
| 601 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | 603 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | ||
| 602 | 604 | ||||
| 605 | + | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 603 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | 606 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | ||
| 607 | + | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 604 | "consume-or-expel-right": ActionConsumeOrExpelRight, | 608 | "consume-or-expel-right": ActionConsumeOrExpelRight, | ||
| 609 | + | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 605 | "consume-or-expel-up": ActionConsumeOrExpelUp, | 610 | "consume-or-expel-up": ActionConsumeOrExpelUp, | ||
| 611 | + | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 606 | "consume-or-expel-down": ActionConsumeOrExpelDown, | 612 | "consume-or-expel-down": ActionConsumeOrExpelDown, | ||
| 607 | 613 | ||||
| 608 | // Focus navigation | 614 | // Focus navigation | ||
M internal/ui/input/shortcuts_test.go +59 -0
| @@ -383,9 +383,68 @@ func TestMapConfigAction_ToggleFloatingPane(t *testing.T) { | |||||
| 383 | } | 383 | } | ||
| 384 | } | 384 | } | ||
| 385 | 385 | ||||
| 386 | + | func TestMapConfigAction_ToggleSystemViews(t *testing.T) { | |||
| 387 | + | tests := []struct { | |||
| 388 | + | name string | |||
| 389 | + | want Action | |||
| 390 | + | }{ | |||
| 391 | + | {name: "toggle_history_systemview", want: ActionToggleHistorySystemView}, | |||
| 392 | + | {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView}, | |||
| 393 | + | {name: "toggle_config_systemview", want: ActionToggleConfigSystemView}, | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | for _, tt := range tests { | |||
| 397 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 398 | + | if got := mapConfigAction(tt.name); got != tt.want { | |||
| 399 | + | t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want) | |||
| 400 | + | } | |||
| 401 | + | }) | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) { | |||
| 406 | + | tests := []struct { | |||
| 407 | + | name string | |||
| 408 | + | want Action | |||
| 409 | + | }{ | |||
| 410 | + | {name: "toggle-history-systemview", want: ActionToggleHistorySystemView}, | |||
| 411 | + | {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView}, | |||
| 412 | + | {name: "toggle-config-systemview", want: ActionToggleConfigSystemView}, | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | for _, tt := range tests { | |||
| 416 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 417 | + | if got := mapConfigAction(tt.name); got != tt.want { | |||
| 418 | + | t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want) | |||
| 419 | + | } | |||
| 420 | + | }) | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | ||||
| 386 | func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) { | 424 | func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) { | ||
| 387 | action := mapConfigAction("toggle-floating-pane") | 425 | action := mapConfigAction("toggle-floating-pane") | ||
| 388 | if action != ActionToggleFloatingPane { | 426 | if action != ActionToggleFloatingPane { | ||
| 389 | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) | 427 | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) | ||
| 390 | } | 428 | } | ||
| 391 | } | 429 | } | ||
| 430 | + | ||||
| 431 | + | func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) { | |||
| 432 | + | actions := globalShortcutActionMap() | |||
| 433 | + | tests := []struct { | |||
| 434 | + | name string | |||
| 435 | + | want Action | |||
| 436 | + | }{ | |||
| 437 | + | {name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft}, | |||
| 438 | + | {name: "consume-or-expel-right", want: ActionConsumeOrExpelRight}, | |||
| 439 | + | {name: "consume-or-expel-up", want: ActionConsumeOrExpelUp}, | |||
| 440 | + | {name: "consume-or-expel-down", want: ActionConsumeOrExpelDown}, | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | for _, tt := range tests { | |||
| 444 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 445 | + | if got := actions[tt.name]; got != tt.want { | |||
| 446 | + | t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want) | |||
| 447 | + | } | |||
| 448 | + | }) | |||
| 449 | + | } | |||
| 450 | + | } | |||
M internal/ui/standalone_runtime_test.go +1 -1
| @@ -74,7 +74,7 @@ func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) { | 76 | func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) { | ||
| 77 | - | for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} { | 77 | + | for _, rawURL := range []string{"dumb://history", "dumb://error", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} { |
| 78 | if urlutil.IsExternalScheme(rawURL) { | 78 | if urlutil.IsExternalScheme(rawURL) { | ||
| 79 | t.Fatalf("expected %q to stay internal", rawURL) | 79 | t.Fatalf("expected %q to stay internal", rawURL) | ||
| 80 | } | 80 | } | ||
A internal/ui/systemviews/action_log.go +20 -0
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func logActionMountError(ctx context.Context, mountErr, actionErr error) { | |||
| 12 | + | if mountErr == nil && actionErr == nil { | |||
| 13 | + | return | |||
| 14 | + | } | |||
| 15 | + | logging.FromContext(ctx). | |||
| 16 | + | Error(). | |||
| 17 | + | Err(mountErr). | |||
| 18 | + | Str("action_error", errorString(actionErr)). | |||
| 19 | + | Msg("failed to mount systemview action error") | |||
| 20 | + | } | |||
A internal/ui/systemviews/action_log_common.go +8 -0
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | func errorString(err error) string { | |||
| 4 | + | if err == nil { | |||
| 5 | + | return "<nil>" | |||
| 6 | + | } | |||
| 7 | + | return err.Error() | |||
| 8 | + | } | |||
A internal/ui/systemviews/action_log_js.go +19 -0
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "syscall/js" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func logActionMountError(_ context.Context, mountErr, actionErr error) { | |||
| 11 | + | if mountErr == nil && actionErr == nil { | |||
| 12 | + | return | |||
| 13 | + | } | |||
| 14 | + | console := js.Global().Get("console") | |||
| 15 | + | if !console.Truthy() || !console.Get("error").Truthy() { | |||
| 16 | + | return | |||
| 17 | + | } | |||
| 18 | + | console.Call("error", "failed to mount systemview action error", errorString(mountErr), errorString(actionErr)) | |||
| 19 | + | } | |||
A internal/ui/systemviews/app.go +832 -0
| @@ -0,0 +1,832 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type Dependencies struct { | |||
| 17 | + | DOM DOM | |||
| 18 | + | Favorites port.SystemviewFavoritesService | |||
| 19 | + | History port.SystemviewHistoryService | |||
| 20 | + | Config port.SystemviewConfigService | |||
| 21 | + | LocationURI string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type App struct { | |||
| 25 | + | // mu protects rendered route state; actionMu protects worker channels. | |||
| 26 | + | // The two locks must not be held at the same time. | |||
| 27 | + | mu sync.Mutex | |||
| 28 | + | actionMu sync.Mutex | |||
| 29 | + | deps Dependencies | |||
| 30 | + | currentRoute Route | |||
| 31 | + | shellTheme shellTheme | |||
| 32 | + | historyEntries []*entity.HistoryEntry | |||
| 33 | + | historyStats *entity.HistoryStats | |||
| 34 | + | historyAnalytics *entity.HistoryAnalytics | |||
| 35 | + | historyDomainStats []*entity.DomainStat | |||
| 36 | + | historyQuery string | |||
| 37 | + | historyDomainFilter string | |||
| 38 | + | historyOffset int | |||
| 39 | + | historyWindowBefore time.Time | |||
| 40 | + | historyWindowAfter time.Time | |||
| 41 | + | historyHasMore bool | |||
| 42 | + | historyNotice string | |||
| 43 | + | historyError string | |||
| 44 | + | favorites []*entity.Favorite | |||
| 45 | + | folders []*entity.Folder | |||
| 46 | + | tags []*entity.Tag | |||
| 47 | + | favoriteFolderFilter *entity.FolderID | |||
| 48 | + | favoriteTagFilter *entity.TagID | |||
| 49 | + | favoritesNotice string | |||
| 50 | + | favoritesError string | |||
| 51 | + | config *dto.SystemviewConfigPayload | |||
| 52 | + | keybindings port.KeybindingsConfig | |||
| 53 | + | configNotice string | |||
| 54 | + | configError string | |||
| 55 | + | renderedHTML string | |||
| 56 | + | renderGeneration uint64 | |||
| 57 | + | closed bool | |||
| 58 | + | actionQueue chan DOMAction | |||
| 59 | + | actionErrorQueue chan error | |||
| 60 | + | actionCtx context.Context | |||
| 61 | + | actionCancel context.CancelFunc | |||
| 62 | + | actionClosed bool | |||
| 63 | + | actionWG sync.WaitGroup | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | const ( | |||
| 67 | + | historyTimelineLimit = 0 | |||
| 68 | + | historySearchLimit = 100 | |||
| 69 | + | ) | |||
| 70 | + | ||||
| 71 | + | func (a *App) lockAction() { | |||
| 72 | + | // Lock order: actionMu is never acquired while App.mu is held. | |||
| 73 | + | a.actionMu.Lock() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (a *App) unlockAction() { | |||
| 77 | + | a.actionMu.Unlock() | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (a *App) lockState() { | |||
| 81 | + | // Lock order: App.mu is never held while acquiring actionMu. | |||
| 82 | + | a.mu.Lock() | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (a *App) unlockState() { | |||
| 86 | + | a.mu.Unlock() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func NewApp(deps Dependencies) *App { | |||
| 90 | + | return &App{deps: deps, currentRoute: RouteUnknown} | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (a *App) Run() error { | |||
| 94 | + | return a.RunWithContext(context.Background()) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (a *App) RunWithContext(ctx context.Context) error { | |||
| 98 | + | if a == nil { | |||
| 99 | + | return errors.New("app is nil") | |||
| 100 | + | } | |||
| 101 | + | if ctx == nil { | |||
| 102 | + | return errors.New("context is nil") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 106 | + | if a.deps.DOM == nil { | |||
| 107 | + | return errors.New("DOM not configured") | |||
| 108 | + | } | |||
| 109 | + | asyncInitial := a.shouldLoadInitialRouteAsync() | |||
| 110 | + | if asyncInitial { | |||
| 111 | + | a.LoadInitialShell(ctx) | |||
| 112 | + | } else if err := a.LoadInitial(ctx); err != nil { | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | if err := a.deps.DOM.Mount(a.renderedHTML); err != nil { | |||
| 117 | + | return err | |||
| 118 | + | } | |||
| 119 | + | if binder, ok := a.deps.DOM.(DOMActionBinder); ok { | |||
| 120 | + | if err := a.bindDOMActions(ctx, binder); err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if asyncInitial { | |||
| 125 | + | a.refreshCurrentRouteAsync(ctx) | |||
| 126 | + | } | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (a *App) shouldLoadInitialRouteAsync() bool { | |||
| 131 | + | return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (a *App) refreshCurrentRouteAsync(ctx context.Context) { | |||
| 135 | + | if a == nil { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | a.lockState() | |||
| 139 | + | snapshot := a.currentHistoryRouteSnapshotLocked() | |||
| 140 | + | generation := a.renderGeneration | |||
| 141 | + | closed := a.closed | |||
| 142 | + | a.unlockState() | |||
| 143 | + | if closed || snapshot.route != RouteHistory { | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | a.lockAction() | |||
| 148 | + | asyncCtx := a.actionCtx | |||
| 149 | + | if asyncCtx == nil { | |||
| 150 | + | asyncCtx = ctx | |||
| 151 | + | } | |||
| 152 | + | ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil | |||
| 153 | + | if ready { | |||
| 154 | + | a.actionWG.Add(1) | |||
| 155 | + | } | |||
| 156 | + | a.unlockAction() | |||
| 157 | + | if !ready { | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | go func() { | |||
| 162 | + | defer a.actionWG.Done() | |||
| 163 | + | if asyncCtx.Err() != nil { | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot) | |||
| 167 | + | ||||
| 168 | + | a.lockState() | |||
| 169 | + | if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation { | |||
| 170 | + | a.unlockState() | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | if err != nil { | |||
| 174 | + | a.renderRouteError(err) | |||
| 175 | + | } else { | |||
| 176 | + | a.commitHistoryRouteResultLocked(result) | |||
| 177 | + | } | |||
| 178 | + | a.renderGeneration++ | |||
| 179 | + | mountGeneration := a.renderGeneration | |||
| 180 | + | html := a.renderedHTML | |||
| 181 | + | a.unlockState() | |||
| 182 | + | ||||
| 183 | + | if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil { | |||
| 184 | + | logActionMountError(asyncCtx, err, nil) | |||
| 185 | + | } | |||
| 186 | + | }() | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error { | |||
| 190 | + | var ( | |||
| 191 | + | created bool | |||
| 192 | + | workerCtx context.Context | |||
| 193 | + | cancel context.CancelFunc | |||
| 194 | + | queue chan DOMAction | |||
| 195 | + | errorQueue chan error | |||
| 196 | + | ) | |||
| 197 | + | ||||
| 198 | + | a.lockAction() | |||
| 199 | + | if a.actionClosed { | |||
| 200 | + | a.unlockAction() | |||
| 201 | + | return errors.New("systemview app is closed") | |||
| 202 | + | } | |||
| 203 | + | if a.actionQueue == nil { | |||
| 204 | + | workerCtx, cancel = context.WithCancel(ctx) | |||
| 205 | + | queue = make(chan DOMAction, 64) | |||
| 206 | + | errorQueue = make(chan error, 1) | |||
| 207 | + | a.actionQueue = queue | |||
| 208 | + | a.actionErrorQueue = errorQueue | |||
| 209 | + | a.actionCtx = workerCtx | |||
| 210 | + | a.actionCancel = cancel | |||
| 211 | + | created = true | |||
| 212 | + | } | |||
| 213 | + | a.unlockAction() | |||
| 214 | + | ||||
| 215 | + | if err := binder.BindActions(func(action DOMAction) { | |||
| 216 | + | if !a.enqueueDOMAction(action) && a.actionWorkerActive() { | |||
| 217 | + | a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action)) | |||
| 218 | + | } | |||
| 219 | + | }); err != nil { | |||
| 220 | + | if created { | |||
| 221 | + | a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue) | |||
| 222 | + | } | |||
| 223 | + | return err | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | if created && !a.startActionWorkers(workerCtx, queue, errorQueue) { | |||
| 227 | + | a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue) | |||
| 228 | + | a.releaseDOMBindings() | |||
| 229 | + | return errors.New("systemview action worker unavailable") | |||
| 230 | + | } | |||
| 231 | + | return nil | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool { | |||
| 235 | + | if a == nil || ctx == nil || queue == nil || errorQueue == nil { | |||
| 236 | + | return false | |||
| 237 | + | } | |||
| 238 | + | a.lockAction() | |||
| 239 | + | ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil | |||
| 240 | + | if ready { | |||
| 241 | + | a.actionWG.Add(2) | |||
| 242 | + | } | |||
| 243 | + | a.unlockAction() | |||
| 244 | + | if !ready { | |||
| 245 | + | return false | |||
| 246 | + | } | |||
| 247 | + | go func() { | |||
| 248 | + | defer a.actionWG.Done() | |||
| 249 | + | a.runActionWorker(ctx, queue) | |||
| 250 | + | }() | |||
| 251 | + | go func() { | |||
| 252 | + | defer a.actionWG.Done() | |||
| 253 | + | a.runActionErrorWorker(ctx, errorQueue) | |||
| 254 | + | }() | |||
| 255 | + | return true | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) { | |||
| 259 | + | if a == nil { | |||
| 260 | + | return | |||
| 261 | + | } | |||
| 262 | + | a.lockAction() | |||
| 263 | + | if a.actionCtx == ctx && a.actionQueue == queue { | |||
| 264 | + | a.actionCancel = nil | |||
| 265 | + | a.actionCtx = nil | |||
| 266 | + | a.actionQueue = nil | |||
| 267 | + | a.actionErrorQueue = nil | |||
| 268 | + | if cancel != nil { | |||
| 269 | + | cancel() | |||
| 270 | + | } | |||
| 271 | + | close(queue) | |||
| 272 | + | close(errorQueue) | |||
| 273 | + | } | |||
| 274 | + | a.unlockAction() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func (a *App) actionWorkerActive() bool { | |||
| 278 | + | if a == nil { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | a.lockAction() | |||
| 282 | + | closed := a.actionClosed | |||
| 283 | + | queue := a.actionQueue | |||
| 284 | + | ctx := a.actionCtx | |||
| 285 | + | a.unlockAction() | |||
| 286 | + | return !closed && queue != nil && ctx != nil && ctx.Err() == nil | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func (a *App) enqueueDOMAction(action DOMAction) bool { | |||
| 290 | + | if a == nil { | |||
| 291 | + | return false | |||
| 292 | + | } | |||
| 293 | + | a.lockAction() | |||
| 294 | + | defer a.unlockAction() | |||
| 295 | + | queue := a.actionQueue | |||
| 296 | + | ctx := a.actionCtx | |||
| 297 | + | if a.actionClosed || queue == nil || ctx == nil { | |||
| 298 | + | return false | |||
| 299 | + | } | |||
| 300 | + | select { | |||
| 301 | + | case <-ctx.Done(): | |||
| 302 | + | return false | |||
| 303 | + | case queue <- action: | |||
| 304 | + | return true | |||
| 305 | + | default: | |||
| 306 | + | return false | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (a *App) enqueueActionError(err error) { | |||
| 311 | + | if a == nil || err == nil { | |||
| 312 | + | return | |||
| 313 | + | } | |||
| 314 | + | a.lockAction() | |||
| 315 | + | defer a.unlockAction() | |||
| 316 | + | queue := a.actionErrorQueue | |||
| 317 | + | ctx := a.actionCtx | |||
| 318 | + | if a.actionClosed || queue == nil || ctx == nil { | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | select { | |||
| 322 | + | case <-ctx.Done(): | |||
| 323 | + | return | |||
| 324 | + | case queue <- err: | |||
| 325 | + | default: | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) { | |||
| 330 | + | for { | |||
| 331 | + | select { | |||
| 332 | + | case <-ctx.Done(): | |||
| 333 | + | return | |||
| 334 | + | case action, ok := <-queue: | |||
| 335 | + | if !ok || ctx.Err() != nil { | |||
| 336 | + | return | |||
| 337 | + | } | |||
| 338 | + | if err := a.HandleDOMAction(ctx, action); err != nil { | |||
| 339 | + | a.enqueueActionError(err) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) { | |||
| 346 | + | for { | |||
| 347 | + | select { | |||
| 348 | + | case <-ctx.Done(): | |||
| 349 | + | return | |||
| 350 | + | case err, ok := <-queue: | |||
| 351 | + | if !ok || ctx.Err() != nil { | |||
| 352 | + | return | |||
| 353 | + | } | |||
| 354 | + | a.surfaceActionError(ctx, err) | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | func (a *App) surfaceActionError(ctx context.Context, err error) { | |||
| 360 | + | if a == nil || err == nil { | |||
| 361 | + | return | |||
| 362 | + | } | |||
| 363 | + | a.lockState() | |||
| 364 | + | a.renderRouteError(err) | |||
| 365 | + | a.renderGeneration++ | |||
| 366 | + | generation := a.renderGeneration | |||
| 367 | + | html := a.renderedHTML | |||
| 368 | + | a.unlockState() | |||
| 369 | + | if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil { | |||
| 370 | + | logActionMountError(ctx, mountErr, err) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func (a *App) Close() { | |||
| 375 | + | if a == nil { | |||
| 376 | + | return | |||
| 377 | + | } | |||
| 378 | + | a.lockState() | |||
| 379 | + | a.closed = true | |||
| 380 | + | a.renderGeneration++ | |||
| 381 | + | a.unlockState() | |||
| 382 | + | ||||
| 383 | + | a.lockAction() | |||
| 384 | + | cancel := a.actionCancel | |||
| 385 | + | actionQueue := a.actionQueue | |||
| 386 | + | actionErrorQueue := a.actionErrorQueue | |||
| 387 | + | a.actionClosed = true | |||
| 388 | + | a.actionCancel = nil | |||
| 389 | + | a.actionCtx = nil | |||
| 390 | + | a.actionQueue = nil | |||
| 391 | + | a.actionErrorQueue = nil | |||
| 392 | + | a.unlockAction() | |||
| 393 | + | ||||
| 394 | + | if cancel != nil { | |||
| 395 | + | cancel() | |||
| 396 | + | } | |||
| 397 | + | a.actionWG.Wait() | |||
| 398 | + | if actionQueue != nil { | |||
| 399 | + | close(actionQueue) | |||
| 400 | + | } | |||
| 401 | + | if actionErrorQueue != nil { | |||
| 402 | + | close(actionErrorQueue) | |||
| 403 | + | } | |||
| 404 | + | a.releaseDOMBindings() | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func (a *App) releaseDOMBindings() { | |||
| 408 | + | if a == nil { | |||
| 409 | + | return | |||
| 410 | + | } | |||
| 411 | + | if releaser, ok := a.deps.DOM.(interface{ Release() }); ok { | |||
| 412 | + | releaser.Release() | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func (a *App) LoadInitialShell(ctx context.Context) { | |||
| 417 | + | if a == nil { | |||
| 418 | + | return | |||
| 419 | + | } | |||
| 420 | + | a.loadShellTheme(ctx) | |||
| 421 | + | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | |||
| 422 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 423 | + | } | |||
| 424 | + | switch a.currentRoute { | |||
| 425 | + | case RouteHistory: | |||
| 426 | + | a.loadHistoryLoadingRoute() | |||
| 427 | + | default: | |||
| 428 | + | a.resetRouteState() | |||
| 429 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 430 | + | route: a.currentRoute, | |||
| 431 | + | title: routeDocumentTitle(a.currentRoute), | |||
| 432 | + | subtitle: routeSubtitle(a.currentRoute), | |||
| 433 | + | body: placeholderHTML(a.currentRoute), | |||
| 434 | + | }, a.shellTheme) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func (a *App) LoadInitial(ctx context.Context) error { | |||
| 439 | + | if a == nil { | |||
| 440 | + | return errors.New("app is nil") | |||
| 441 | + | } | |||
| 442 | + | a.loadShellTheme(ctx) | |||
| 443 | + | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | |||
| 444 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | if err := a.loadCurrentRoute(ctx); err != nil { | |||
| 448 | + | a.renderRouteError(err) | |||
| 449 | + | } | |||
| 450 | + | return nil | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func (a *App) loadCurrentRoute(ctx context.Context) error { | |||
| 454 | + | switch a.currentRoute { | |||
| 455 | + | case RouteHistory: | |||
| 456 | + | return a.loadHistoryRoute(ctx) | |||
| 457 | + | case RouteFavorites: | |||
| 458 | + | return a.loadFavoritesRoute(ctx) | |||
| 459 | + | case RouteConfig: | |||
| 460 | + | return a.loadConfigRoute(ctx) | |||
| 461 | + | default: | |||
| 462 | + | a.resetRouteState() | |||
| 463 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 464 | + | route: a.currentRoute, | |||
| 465 | + | title: routeDocumentTitle(a.currentRoute), | |||
| 466 | + | subtitle: string(a.currentRoute), | |||
| 467 | + | body: placeholderHTML(a.currentRoute), | |||
| 468 | + | }, a.shellTheme) | |||
| 469 | + | return nil | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func (a *App) renderRouteError(err error) { | |||
| 474 | + | a.resetRouteState() | |||
| 475 | + | message := "unknown error" | |||
| 476 | + | if err != nil { | |||
| 477 | + | message = err.Error() | |||
| 478 | + | } | |||
| 479 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 480 | + | route: a.currentRoute, | |||
| 481 | + | title: routeDocumentTitle(a.currentRoute) + " — Error", | |||
| 482 | + | subtitle: routeSubtitle(a.currentRoute), | |||
| 483 | + | body: errorStateHTML(message), | |||
| 484 | + | }, a.shellTheme) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func routeDocumentTitle(route Route) string { | |||
| 488 | + | switch route { | |||
| 489 | + | case RouteHistory: | |||
| 490 | + | return "History" | |||
| 491 | + | case RouteFavorites: | |||
| 492 | + | return "Favorites" | |||
| 493 | + | case RouteConfig: | |||
| 494 | + | return "Config" | |||
| 495 | + | default: | |||
| 496 | + | return "Dumber System View" | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func routeSubtitle(route Route) string { | |||
| 501 | + | switch route { | |||
| 502 | + | case RouteHistory: | |||
| 503 | + | return "Recent visits" | |||
| 504 | + | case RouteFavorites: | |||
| 505 | + | return "Saved bookmarks" | |||
| 506 | + | case RouteConfig: | |||
| 507 | + | return "Browser settings" | |||
| 508 | + | default: | |||
| 509 | + | return string(route) | |||
| 510 | + | } | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | func (a *App) loadHistoryLoadingRoute() { | |||
| 514 | + | a.favorites = nil | |||
| 515 | + | a.folders = nil | |||
| 516 | + | a.tags = nil | |||
| 517 | + | a.historyEntries = nil | |||
| 518 | + | a.historyStats = nil | |||
| 519 | + | a.historyAnalytics = nil | |||
| 520 | + | a.historyDomainStats = nil | |||
| 521 | + | a.historyNotice = "" | |||
| 522 | + | a.historyError = "" | |||
| 523 | + | data := historyRenderData{ | |||
| 524 | + | Query: a.historyQuery, | |||
| 525 | + | DomainFilter: a.historyDomainFilter, | |||
| 526 | + | Offset: a.historyOffset, | |||
| 527 | + | Limit: historyTimelineLimit, | |||
| 528 | + | WindowBefore: a.historyWindowBefore, | |||
| 529 | + | WindowAfter: a.historyWindowAfter, | |||
| 530 | + | HasMore: a.historyHasMore, | |||
| 531 | + | Notice: a.historyNotice, | |||
| 532 | + | Error: a.historyError, | |||
| 533 | + | Loading: true, | |||
| 534 | + | } | |||
| 535 | + | title := historyDocumentTitle(data) | |||
| 536 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 537 | + | route: RouteHistory, | |||
| 538 | + | title: title, | |||
| 539 | + | subtitle: "History", | |||
| 540 | + | subtitleDetail: historyTitleDetail(data), | |||
| 541 | + | body: historyHTML(data), | |||
| 542 | + | }, a.shellTheme) | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 546 | + | if a.deps.History == nil { | |||
| 547 | + | a.resetRouteState() | |||
| 548 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 549 | + | route: a.currentRoute, | |||
| 550 | + | title: routeDocumentTitle(RouteHistory), | |||
| 551 | + | subtitle: routeDocumentTitle(RouteHistory), | |||
| 552 | + | body: placeholderHTML(a.currentRoute), | |||
| 553 | + | }, a.shellTheme) | |||
| 554 | + | return nil | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked()) | |||
| 558 | + | if err != nil { | |||
| 559 | + | return err | |||
| 560 | + | } | |||
| 561 | + | a.commitHistoryRouteResultLocked(result) | |||
| 562 | + | return nil | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | type historyRouteSnapshot struct { | |||
| 566 | + | route Route | |||
| 567 | + | history port.SystemviewHistoryService | |||
| 568 | + | shellTheme shellTheme | |||
| 569 | + | query string | |||
| 570 | + | domainFilter string | |||
| 571 | + | offset int | |||
| 572 | + | windowBefore time.Time | |||
| 573 | + | windowAfter time.Time | |||
| 574 | + | notice string | |||
| 575 | + | error string | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | type historyRouteResult struct { | |||
| 579 | + | entries []*entity.HistoryEntry | |||
| 580 | + | stats *entity.HistoryStats | |||
| 581 | + | domains []*entity.DomainStat | |||
| 582 | + | windowBefore time.Time | |||
| 583 | + | windowAfter time.Time | |||
| 584 | + | hasMore bool | |||
| 585 | + | html string | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot { | |||
| 589 | + | return historyRouteSnapshot{ | |||
| 590 | + | route: a.currentRoute, | |||
| 591 | + | history: a.deps.History, | |||
| 592 | + | shellTheme: a.shellTheme, | |||
| 593 | + | query: a.historyQuery, | |||
| 594 | + | domainFilter: a.historyDomainFilter, | |||
| 595 | + | offset: a.historyOffset, | |||
| 596 | + | windowBefore: a.historyWindowBefore, | |||
| 597 | + | windowAfter: a.historyWindowAfter, | |||
| 598 | + | notice: a.historyNotice, | |||
| 599 | + | error: a.historyError, | |||
| 600 | + | } | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) { | |||
| 604 | + | if snapshot.history == nil { | |||
| 605 | + | return historyRouteResult{ | |||
| 606 | + | html: renderAppFrame(renderedPage{ | |||
| 607 | + | route: snapshot.route, | |||
| 608 | + | title: routeDocumentTitle(RouteHistory), | |||
| 609 | + | subtitle: routeDocumentTitle(RouteHistory), | |||
| 610 | + | body: placeholderHTML(snapshot.route), | |||
| 611 | + | }, snapshot.shellTheme), | |||
| 612 | + | }, nil | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot) | |||
| 616 | + | if err != nil { | |||
| 617 | + | return historyRouteResult{}, err | |||
| 618 | + | } | |||
| 619 | + | stats, err := snapshot.history.Stats(ctx) | |||
| 620 | + | if err != nil { | |||
| 621 | + | return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err) | |||
| 622 | + | } | |||
| 623 | + | domains, domainsErr := snapshot.history.DomainStats(ctx, 10) | |||
| 624 | + | if domainsErr != nil { | |||
| 625 | + | domains = nil | |||
| 626 | + | } | |||
| 627 | + | result := historyRouteResult{ | |||
| 628 | + | entries: entries, | |||
| 629 | + | stats: stats, | |||
| 630 | + | domains: domains, | |||
| 631 | + | windowBefore: snapshot.windowBefore, | |||
| 632 | + | windowAfter: snapshot.windowAfter, | |||
| 633 | + | } | |||
| 634 | + | if window != nil { | |||
| 635 | + | result.windowBefore = window.Before | |||
| 636 | + | result.windowAfter = window.After | |||
| 637 | + | result.hasMore = window.HasMore | |||
| 638 | + | } | |||
| 639 | + | data := historyRenderData{ | |||
| 640 | + | Entries: entries, | |||
| 641 | + | Stats: stats, | |||
| 642 | + | Analytics: nil, | |||
| 643 | + | Domains: domains, | |||
| 644 | + | Query: snapshot.query, | |||
| 645 | + | DomainFilter: snapshot.domainFilter, | |||
| 646 | + | Offset: snapshot.offset, | |||
| 647 | + | Limit: historyTimelineLimit, | |||
| 648 | + | WindowBefore: result.windowBefore, | |||
| 649 | + | WindowAfter: result.windowAfter, | |||
| 650 | + | HasMore: result.hasMore, | |||
| 651 | + | Notice: snapshot.notice, | |||
| 652 | + | Error: snapshot.error, | |||
| 653 | + | } | |||
| 654 | + | title := historyDocumentTitle(data) | |||
| 655 | + | result.html = renderAppFrame(renderedPage{ | |||
| 656 | + | route: RouteHistory, | |||
| 657 | + | title: title, | |||
| 658 | + | subtitle: "History", | |||
| 659 | + | subtitleDetail: historyTitleDetail(data), | |||
| 660 | + | body: historyHTML(data), | |||
| 661 | + | }, snapshot.shellTheme) | |||
| 662 | + | return result, nil | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) { | |||
| 666 | + | query := strings.TrimSpace(snapshot.query) | |||
| 667 | + | domain := strings.TrimSpace(snapshot.domainFilter) | |||
| 668 | + | if query != "" { | |||
| 669 | + | entries, err := snapshot.history.Search(ctx, query, historySearchLimit) | |||
| 670 | + | return nil, entries, err | |||
| 671 | + | } | |||
| 672 | + | window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain) | |||
| 673 | + | if err != nil { | |||
| 674 | + | return nil, nil, err | |||
| 675 | + | } | |||
| 676 | + | if window == nil { | |||
| 677 | + | return nil, nil, nil | |||
| 678 | + | } | |||
| 679 | + | return window, window.Entries, nil | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) { | |||
| 683 | + | a.favorites = nil | |||
| 684 | + | a.folders = nil | |||
| 685 | + | a.tags = nil | |||
| 686 | + | a.historyEntries = result.entries | |||
| 687 | + | a.historyStats = result.stats | |||
| 688 | + | a.historyAnalytics = nil | |||
| 689 | + | a.historyDomainStats = result.domains | |||
| 690 | + | a.historyWindowBefore = result.windowBefore | |||
| 691 | + | a.historyWindowAfter = result.windowAfter | |||
| 692 | + | a.historyHasMore = result.hasMore | |||
| 693 | + | a.renderedHTML = result.html | |||
| 694 | + | } | |||
| 695 | + | ||||
| 696 | + | func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||
| 697 | + | if a.deps.Favorites == nil { | |||
| 698 | + | a.resetRouteState() | |||
| 699 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 700 | + | route: a.currentRoute, | |||
| 701 | + | title: routeDocumentTitle(RouteFavorites), | |||
| 702 | + | subtitle: "Saved bookmarks", | |||
| 703 | + | body: placeholderHTML(a.currentRoute), | |||
| 704 | + | }, a.shellTheme) | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | favorites, err := a.deps.Favorites.List(ctx) | |||
| 709 | + | if err != nil { | |||
| 710 | + | return err | |||
| 711 | + | } | |||
| 712 | + | folders, err := a.deps.Favorites.ListFolders(ctx) | |||
| 713 | + | if err != nil { | |||
| 714 | + | return err | |||
| 715 | + | } | |||
| 716 | + | tags, err := a.deps.Favorites.ListTags(ctx) | |||
| 717 | + | if err != nil { | |||
| 718 | + | return err | |||
| 719 | + | } | |||
| 720 | + | ||||
| 721 | + | a.historyEntries = nil | |||
| 722 | + | favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter) | |||
| 723 | + | a.favorites = favorites | |||
| 724 | + | a.folders = folders | |||
| 725 | + | a.tags = tags | |||
| 726 | + | data := favoritesRenderData{ | |||
| 727 | + | Favorites: favorites, | |||
| 728 | + | Folders: folders, | |||
| 729 | + | Tags: tags, | |||
| 730 | + | FolderFilter: a.favoriteFolderFilter, | |||
| 731 | + | TagFilter: a.favoriteTagFilter, | |||
| 732 | + | Notice: a.favoritesNotice, | |||
| 733 | + | Error: a.favoritesError, | |||
| 734 | + | } | |||
| 735 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 736 | + | route: RouteFavorites, | |||
| 737 | + | title: favoritesDocumentTitle(data), | |||
| 738 | + | subtitle: "Saved bookmarks", | |||
| 739 | + | body: favoritesHTML(data), | |||
| 740 | + | }, a.shellTheme) | |||
| 741 | + | return nil | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | func (a *App) loadConfigRoute(ctx context.Context) error { | |||
| 745 | + | if a.deps.Config == nil { | |||
| 746 | + | a.resetRouteState() | |||
| 747 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 748 | + | route: a.currentRoute, | |||
| 749 | + | title: routeDocumentTitle(RouteConfig), | |||
| 750 | + | subtitle: "Browser settings", | |||
| 751 | + | body: placeholderHTML(a.currentRoute), | |||
| 752 | + | }, a.shellTheme) | |||
| 753 | + | return nil | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | config, err := a.deps.Config.Current(ctx) | |||
| 757 | + | if err != nil { | |||
| 758 | + | return err | |||
| 759 | + | } | |||
| 760 | + | keybindings, err := a.deps.Config.GetKeybindings(ctx) | |||
| 761 | + | if err != nil { | |||
| 762 | + | return err | |||
| 763 | + | } | |||
| 764 | + | ||||
| 765 | + | a.historyEntries = nil | |||
| 766 | + | a.favorites = nil | |||
| 767 | + | a.folders = nil | |||
| 768 | + | a.tags = nil | |||
| 769 | + | a.config = &config | |||
| 770 | + | a.keybindings = keybindings | |||
| 771 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 772 | + | route: RouteConfig, | |||
| 773 | + | title: "Config — Dumber", | |||
| 774 | + | subtitle: "Browser settings", | |||
| 775 | + | body: configHTML(configRenderData{ | |||
| 776 | + | Config: config, | |||
| 777 | + | Keybindings: keybindings, | |||
| 778 | + | Notice: a.configNotice, | |||
| 779 | + | Error: a.configError, | |||
| 780 | + | }), | |||
| 781 | + | }, a.shellTheme) | |||
| 782 | + | return nil | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | func (a *App) loadShellTheme(ctx context.Context) { | |||
| 786 | + | if a == nil { | |||
| 787 | + | return | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | a.shellTheme = shellTheme{} | |||
| 791 | + | if a.deps.Config == nil { | |||
| 792 | + | return | |||
| 793 | + | } | |||
| 794 | + | ||||
| 795 | + | config, err := a.deps.Config.Current(ctx) | |||
| 796 | + | if err == nil { | |||
| 797 | + | a.shellTheme = resolveShellTheme(config.Appearance) | |||
| 798 | + | } | |||
| 799 | + | } | |||
| 800 | + | ||||
| 801 | + | func (a *App) resetRouteState() { | |||
| 802 | + | a.historyEntries = nil | |||
| 803 | + | a.historyStats = nil | |||
| 804 | + | a.historyAnalytics = nil | |||
| 805 | + | a.historyDomainStats = nil | |||
| 806 | + | a.historyQuery = "" | |||
| 807 | + | a.historyDomainFilter = "" | |||
| 808 | + | a.historyOffset = 0 | |||
| 809 | + | a.historyWindowBefore = time.Time{} | |||
| 810 | + | a.historyWindowAfter = time.Time{} | |||
| 811 | + | a.historyHasMore = false | |||
| 812 | + | a.historyNotice = "" | |||
| 813 | + | a.historyError = "" | |||
| 814 | + | a.favorites = nil | |||
| 815 | + | a.folders = nil | |||
| 816 | + | a.tags = nil | |||
| 817 | + | a.favoriteFolderFilter = nil | |||
| 818 | + | a.favoriteTagFilter = nil | |||
| 819 | + | a.favoritesNotice = "" | |||
| 820 | + | a.favoritesError = "" | |||
| 821 | + | a.config = nil | |||
| 822 | + | a.keybindings = port.KeybindingsConfig{} | |||
| 823 | + | a.configNotice = "" | |||
| 824 | + | a.configError = "" | |||
| 825 | + | } | |||
| 826 | + | ||||
| 827 | + | func (a *App) CurrentRoute() Route { | |||
| 828 | + | if a == nil { | |||
| 829 | + | return RouteUnknown | |||
| 830 | + | } | |||
| 831 | + | return a.currentRoute | |||
| 832 | + | } | |||
A internal/ui/systemviews/app_test.go +1432 -0
| @@ -0,0 +1,1432 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestParseRoute(t *testing.T) { | |||
| 18 | + | t.Parallel() | |||
| 19 | + | ||||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | uri string | |||
| 23 | + | want Route | |||
| 24 | + | }{ | |||
| 25 | + | {name: "empty", uri: "", want: RouteUnknown}, | |||
| 26 | + | {name: "scheme only", uri: "://", want: RouteUnknown}, | |||
| 27 | + | {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown}, | |||
| 28 | + | {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown}, | |||
| 29 | + | {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown}, | |||
| 30 | + | {name: "history host", uri: "dumb://history", want: RouteHistory}, | |||
| 31 | + | {name: "history opaque", uri: "dumb:history", want: RouteHistory}, | |||
| 32 | + | {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites}, | |||
| 33 | + | {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites}, | |||
| 34 | + | {name: "config host", uri: "dumb://config", want: RouteConfig}, | |||
| 35 | + | {name: "config opaque", uri: "dumb:config", want: RouteConfig}, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | for _, tt := range tests { | |||
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 40 | + | t.Parallel() | |||
| 41 | + | if got := ParseRoute(tt.uri); got != tt.want { | |||
| 42 | + | t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want) | |||
| 43 | + | } | |||
| 44 | + | }) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) { | |||
| 49 | + | t.Parallel() | |||
| 50 | + | ||||
| 51 | + | dom := &fakeDOM{} | |||
| 52 | + | app := NewApp(Dependencies{ | |||
| 53 | + | DOM: dom, | |||
| 54 | + | LocationURI: "dumb://history", | |||
| 55 | + | }) | |||
| 56 | + | ||||
| 57 | + | require.NoError(t, app.Run()) | |||
| 58 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |||
| 59 | + | assert.True(t, dom.Mounted()) | |||
| 60 | + | assert.Contains(t, dom.HTML(), "history") | |||
| 61 | + | assert.Contains(t, dom.HTML(), "systemviews") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |||
| 65 | + | t.Parallel() | |||
| 66 | + | ||||
| 67 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | |||
| 68 | + | URL: "https://example.com", | |||
| 69 | + | Title: "Example", | |||
| 70 | + | }}} | |||
| 71 | + | ||||
| 72 | + | app := NewApp(Dependencies{ | |||
| 73 | + | History: history, | |||
| 74 | + | LocationURI: "dumb://history", | |||
| 75 | + | }) | |||
| 76 | + | ||||
| 77 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 78 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |||
| 79 | + | assert.Len(t, app.historyEntries, 1) | |||
| 80 | + | assert.Contains(t, app.renderedHTML, "Example") | |||
| 81 | + | assert.Contains(t, app.renderedHTML, "https://example.com") | |||
| 82 | + | assert.True(t, history.called) | |||
| 83 | + | assert.False(t, history.analyticsCalled) | |||
| 84 | + | assert.Equal(t, 0, history.limit) | |||
| 85 | + | assert.Equal(t, 0, history.offset) | |||
| 86 | + | ||||
| 87 | + | // Shell frame present, no full document wrapper. | |||
| 88 | + | assert.NotContains(t, app.renderedHTML, "<html") | |||
| 89 | + | assert.NotContains(t, app.renderedHTML, "<head") | |||
| 90 | + | assert.NotContains(t, app.renderedHTML, "<body") | |||
| 91 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |||
| 92 | + | assert.Contains(t, app.renderedHTML, `data-route="history"`) | |||
| 93 | + | assert.Contains(t, app.renderedHTML, `data-page-title="History - 1 entry, 1 visit, 0 days"`) | |||
| 94 | + | assert.Contains(t, app.renderedHTML, `sv-section`) | |||
| 95 | + | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) { | |||
| 99 | + | t.Parallel() | |||
| 100 | + | ||||
| 101 | + | history := &fakeHistoryService{ | |||
| 102 | + | entries: []*entity.HistoryEntry{ | |||
| 103 | + | {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2}, | |||
| 104 | + | {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3}, | |||
| 105 | + | }, | |||
| 106 | + | stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121}, | |||
| 107 | + | } | |||
| 108 | + | app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"}) | |||
| 109 | + | ||||
| 110 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 111 | + | assert.True(t, history.statsCalled) | |||
| 112 | + | assert.Contains(t, app.renderedHTML, "Loaded 2 items") | |||
| 113 | + | assert.Contains(t, app.renderedHTML, `data-page-title="History - 8904 entries, 40947 visits, 121 days"`) | |||
| 114 | + | assert.Contains(t, app.renderedHTML, `<h1 class="sv-title">History <span class="sv-title-detail">- 8904 entries, 40947 visits, 121 days</span></h1>`) | |||
| 115 | + | assert.NotContains(t, app.renderedHTML, `sv-history-summary`) | |||
| 116 | + | assert.NotContains(t, app.renderedHTML, `sv-stat-value`) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) { | |||
| 120 | + | t.Parallel() | |||
| 121 | + | ||||
| 122 | + | dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}} | |||
| 123 | + | history := &fakeHistoryService{ | |||
| 124 | + | entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}}, | |||
| 125 | + | timelineStarted: make(chan struct{}), | |||
| 126 | + | releaseTimeline: make(chan struct{}), | |||
| 127 | + | } | |||
| 128 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 129 | + | ||||
| 130 | + | require.NoError(t, app.RunWithContext(context.Background())) | |||
| 131 | + | loadingHTML := receiveMount(t, dom.mounts) | |||
| 132 | + | assert.Contains(t, loadingHTML, "Loading history") | |||
| 133 | + | assert.NotContains(t, loadingHTML, "Loaded entry") | |||
| 134 | + | ||||
| 135 | + | select { | |||
| 136 | + | case <-history.timelineStarted: | |||
| 137 | + | case <-time.After(time.Second): | |||
| 138 | + | t.Fatal("history timeline was not started asynchronously") | |||
| 139 | + | } | |||
| 140 | + | close(history.releaseTimeline) | |||
| 141 | + | ||||
| 142 | + | hydratedHTML := receiveMount(t, dom.mounts) | |||
| 143 | + | assert.Contains(t, hydratedHTML, "Loaded entry") | |||
| 144 | + | assert.Contains(t, hydratedHTML, "Loaded 1 item") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) { | |||
| 148 | + | t.Parallel() | |||
| 149 | + | ||||
| 150 | + | dom := &fakeDOM{mounts: make(chan string, 4)} | |||
| 151 | + | app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"}) | |||
| 152 | + | app.currentRoute = RouteHistory | |||
| 153 | + | staleHTML := "<div>Stale hydrated entry</div>" | |||
| 154 | + | staleGeneration := app.renderGeneration | |||
| 155 | + | ctx := context.Background() | |||
| 156 | + | ||||
| 157 | + | app.surfaceActionError(ctx, errors.New("action worker failed")) | |||
| 158 | + | errorHTML := receiveMount(t, dom.mounts) | |||
| 159 | + | assert.Contains(t, errorHTML, "action worker failed") | |||
| 160 | + | assert.NotContains(t, errorHTML, "Stale hydrated entry") | |||
| 161 | + | ||||
| 162 | + | require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration)) | |||
| 163 | + | assertNoMount(t, dom.mounts, 100*time.Millisecond) | |||
| 164 | + | assert.Contains(t, dom.HTML(), "action worker failed") | |||
| 165 | + | assert.NotContains(t, dom.HTML(), "Stale hydrated entry") | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) { | |||
| 169 | + | t.Parallel() | |||
| 170 | + | ||||
| 171 | + | dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}} | |||
| 172 | + | history := &fakeHistoryService{ | |||
| 173 | + | entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}}, | |||
| 174 | + | timelineStarted: make(chan struct{}), | |||
| 175 | + | releaseTimeline: make(chan struct{}), | |||
| 176 | + | } | |||
| 177 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 178 | + | ctx := context.Background() | |||
| 179 | + | ||||
| 180 | + | require.NoError(t, app.RunWithContext(ctx)) | |||
| 181 | + | loadingHTML := receiveMount(t, dom.mounts) | |||
| 182 | + | assert.Contains(t, loadingHTML, "Loading history") | |||
| 183 | + | ||||
| 184 | + | select { | |||
| 185 | + | case <-history.timelineStarted: | |||
| 186 | + | case <-time.After(time.Second): | |||
| 187 | + | t.Fatal("history timeline was not started asynchronously") | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | app.surfaceActionError(ctx, errors.New("action worker failed")) | |||
| 191 | + | errorHTML := receiveMount(t, dom.mounts) | |||
| 192 | + | assert.Contains(t, errorHTML, "action worker failed") | |||
| 193 | + | assert.NotContains(t, errorHTML, "Stale hydrated entry") | |||
| 194 | + | ||||
| 195 | + | close(history.releaseTimeline) | |||
| 196 | + | assertNoMount(t, dom.mounts, 100*time.Millisecond) | |||
| 197 | + | assert.Contains(t, dom.HTML(), "action worker failed") | |||
| 198 | + | assert.NotContains(t, dom.HTML(), "Stale hydrated entry") | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) { | |||
| 202 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 203 | + | app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"}) | |||
| 204 | + | app.currentRoute = RouteHistory | |||
| 205 | + | app.historyWindowAfter = cursor | |||
| 206 | + | ||||
| 207 | + | snapshot := app.currentHistoryRouteSnapshotLocked() | |||
| 208 | + | assert.Equal(t, cursor, snapshot.windowAfter) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||
| 212 | + | t.Parallel() | |||
| 213 | + | ||||
| 214 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 215 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{ | |||
| 216 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |||
| 217 | + | Before: cursor, | |||
| 218 | + | After: cursor.Add(-24 * time.Hour), | |||
| 219 | + | HasMore: true, | |||
| 220 | + | }} | |||
| 221 | + | dom := &fakeActionDOM{} | |||
| 222 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 223 | + | app.currentRoute = RouteHistory | |||
| 224 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |||
| 225 | + | app.historyWindowAfter = cursor | |||
| 226 | + | app.historyHasMore = true | |||
| 227 | + | ||||
| 228 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 229 | + | Action: historyActionLoadMore, | |||
| 230 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |||
| 231 | + | })) | |||
| 232 | + | assert.Equal(t, cursor, history.windowBefore) | |||
| 233 | + | assert.Len(t, app.historyEntries, 2) | |||
| 234 | + | assert.Contains(t, dom.AppendedHTML(), "Older entry") | |||
| 235 | + | assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`) | |||
| 236 | + | assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page") | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||
| 240 | + | t.Parallel() | |||
| 241 | + | ||||
| 242 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 243 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{ | |||
| 244 | + | Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}}, | |||
| 245 | + | Before: cursor, | |||
| 246 | + | After: cursor.Add(-24 * time.Hour), | |||
| 247 | + | HasMore: true, | |||
| 248 | + | }} | |||
| 249 | + | dom := &fakeDOM{} | |||
| 250 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 251 | + | app.currentRoute = RouteHistory | |||
| 252 | + | app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}} | |||
| 253 | + | app.historyWindowAfter = cursor | |||
| 254 | + | app.historyHasMore = true | |||
| 255 | + | ||||
| 256 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 257 | + | Action: historyActionLoadMore, | |||
| 258 | + | Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)}, | |||
| 259 | + | })) | |||
| 260 | + | assert.Equal(t, cursor, history.windowBefore) | |||
| 261 | + | assert.Len(t, app.historyEntries, 2) | |||
| 262 | + | assert.Contains(t, dom.HTML(), "Older entry") | |||
| 263 | + | assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | |||
| 267 | + | t.Parallel() | |||
| 268 | + | ||||
| 269 | + | cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC) | |||
| 270 | + | history := &fakeHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}} | |||
| 271 | + | dom := &fakeActionDOM{} | |||
| 272 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 273 | + | app.currentRoute = RouteHistory | |||
| 274 | + | app.historyWindowAfter = cursor | |||
| 275 | + | app.historyHasMore = true | |||
| 276 | + | ||||
| 277 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 278 | + | Action: historyActionLoadMore, | |||
| 279 | + | Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)}, | |||
| 280 | + | })) | |||
| 281 | + | assert.False(t, history.called) | |||
| 282 | + | assert.Empty(t, dom.AppendedHTML()) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | |||
| 286 | + | t.Parallel() | |||
| 287 | + | ||||
| 288 | + | history := &fakeHistoryService{ | |||
| 289 | + | entries: []*entity.HistoryEntry{{ | |||
| 290 | + | ID: 42, | |||
| 291 | + | URL: "https://example.com/page", | |||
| 292 | + | Title: "Example", | |||
| 293 | + | }}, | |||
| 294 | + | domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}}, | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | app := NewApp(Dependencies{ | |||
| 298 | + | History: history, | |||
| 299 | + | LocationURI: "dumb://history", | |||
| 300 | + | }) | |||
| 301 | + | ||||
| 302 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 303 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`) | |||
| 304 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`) | |||
| 305 | + | assert.Contains(t, app.renderedHTML, `data-id="42"`) | |||
| 306 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`) | |||
| 307 | + | assert.Contains(t, app.renderedHTML, `data-range="hour"`) | |||
| 308 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`) | |||
| 309 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`) | |||
| 310 | + | assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`) | |||
| 311 | + | assert.Contains(t, app.renderedHTML, `>example.com</button>`) | |||
| 312 | + | assert.Contains(t, app.renderedHTML, `data-sv-history-search data-sv-autofocus`) | |||
| 313 | + | assert.NotContains(t, app.renderedHTML, "Keys:") | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 317 | + | dom := &fakeDOM{} | |||
| 318 | + | history := &fakeHistoryService{ | |||
| 319 | + | entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}}, | |||
| 320 | + | searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}, | |||
| 321 | + | } | |||
| 322 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 323 | + | ||||
| 324 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 325 | + | Action: historyActionSearch, | |||
| 326 | + | Data: map[string]string{"query": " example "}, | |||
| 327 | + | })) | |||
| 328 | + | assert.True(t, history.searchCalled) | |||
| 329 | + | assert.Equal(t, "example", history.query) | |||
| 330 | + | assert.Contains(t, dom.HTML(), "Search result") | |||
| 331 | + | assert.Contains(t, dom.HTML(), "Query: example") | |||
| 332 | + | ||||
| 333 | + | app.historyOffset = historyTimelineLimit | |||
| 334 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 335 | + | Action: historyActionDeleteEntry, | |||
| 336 | + | Data: map[string]string{"id": "42"}, | |||
| 337 | + | })) | |||
| 338 | + | assert.Equal(t, int64(42), history.deletedEntryID) | |||
| 339 | + | assert.Equal(t, 0, app.historyOffset) | |||
| 340 | + | assert.Contains(t, dom.HTML(), "Deleted history entry") | |||
| 341 | + | ||||
| 342 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 343 | + | Action: historyActionDeleteRange, | |||
| 344 | + | Data: map[string]string{"range": "week"}, | |||
| 345 | + | })) | |||
| 346 | + | assert.Equal(t, "week", history.deletedRangeID) | |||
| 347 | + | assert.Contains(t, dom.HTML(), "Deleted history from this week") | |||
| 348 | + | ||||
| 349 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear})) | |||
| 350 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 351 | + | Action: historyActionFilterDomain, | |||
| 352 | + | Data: map[string]string{"domain": "example.com"}, | |||
| 353 | + | })) | |||
| 354 | + | assert.True(t, history.called) | |||
| 355 | + | assert.Equal(t, "example.com", history.windowDomain) | |||
| 356 | + | assert.Empty(t, app.historyQuery) | |||
| 357 | + | ||||
| 358 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 359 | + | Action: historyActionDeleteDomain, | |||
| 360 | + | Data: map[string]string{"domain": "example.com"}, | |||
| 361 | + | })) | |||
| 362 | + | assert.Equal(t, "example.com", history.deletedDomain) | |||
| 363 | + | assert.Contains(t, dom.HTML(), "Deleted history for example.com") | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) { | |||
| 367 | + | dom := &fakeActionDOM{} | |||
| 368 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}} | |||
| 369 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 370 | + | ||||
| 371 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 372 | + | defer cancel() | |||
| 373 | + | require.NoError(t, app.RunWithContext(ctx)) | |||
| 374 | + | require.NotNil(t, dom.handler) | |||
| 375 | + | ||||
| 376 | + | app.Close() | |||
| 377 | + | assert.True(t, dom.released) | |||
| 378 | + | assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear})) | |||
| 379 | + | require.NotPanics(t, func() { | |||
| 380 | + | dom.handler(DOMAction{Action: historyActionClear}) | |||
| 381 | + | }) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) { | |||
| 385 | + | bindErr := errors.New("bind failed") | |||
| 386 | + | dom := &failingActionDOM{bindErr: bindErr} | |||
| 387 | + | app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"}) | |||
| 388 | + | ||||
| 389 | + | err := app.RunWithContext(context.Background()) | |||
| 390 | + | require.ErrorIs(t, err, bindErr) | |||
| 391 | + | assert.Nil(t, app.actionQueue) | |||
| 392 | + | assert.Nil(t, app.actionErrorQueue) | |||
| 393 | + | assert.Nil(t, app.actionCtx) | |||
| 394 | + | assert.False(t, app.actionWorkerActive()) | |||
| 395 | + | require.NotPanics(t, app.Close) | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) { | |||
| 399 | + | dom := &fakeActionDOM{} | |||
| 400 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}} | |||
| 401 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 402 | + | ||||
| 403 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 404 | + | cancel() | |||
| 405 | + | err := app.RunWithContext(ctx) | |||
| 406 | + | ||||
| 407 | + | require.ErrorContains(t, err, "systemview action worker unavailable") | |||
| 408 | + | assert.True(t, dom.released) | |||
| 409 | + | assert.Nil(t, app.actionQueue) | |||
| 410 | + | assert.Nil(t, app.actionErrorQueue) | |||
| 411 | + | assert.Nil(t, app.actionCtx) | |||
| 412 | + | assert.False(t, app.actionWorkerActive()) | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) { | |||
| 416 | + | t.Parallel() | |||
| 417 | + | ||||
| 418 | + | history := &fakeHistoryService{err: errors.New("database unavailable")} | |||
| 419 | + | app := NewApp(Dependencies{ | |||
| 420 | + | History: history, | |||
| 421 | + | LocationURI: "dumb://history", | |||
| 422 | + | }) | |||
| 423 | + | ||||
| 424 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 425 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |||
| 426 | + | assert.True(t, history.called) | |||
| 427 | + | assert.Contains(t, app.renderedHTML, "Could not load this system view") | |||
| 428 | + | assert.Contains(t, app.renderedHTML, "database unavailable") | |||
| 429 | + | assert.Contains(t, app.renderedHTML, `role="alert"`) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) { | |||
| 433 | + | t.Parallel() | |||
| 434 | + | ||||
| 435 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | |||
| 436 | + | URL: "https://example.com", | |||
| 437 | + | Title: "Example", | |||
| 438 | + | }}} | |||
| 439 | + | config := &fakeConfigService{current: dto.SystemviewConfigPayload{ | |||
| 440 | + | Appearance: dto.WebUIAppearanceConfig{ | |||
| 441 | + | ColorScheme: "prefer-light", | |||
| 442 | + | LightPalette: dto.ColorPalette{ | |||
| 443 | + | Background: "#ffffff", | |||
| 444 | + | Surface: "#fafafa", | |||
| 445 | + | SurfaceVariant: "#eeeeee", | |||
| 446 | + | Text: "#111111", | |||
| 447 | + | Muted: "#666666", | |||
| 448 | + | Accent: "#0055ff", | |||
| 449 | + | Border: "#dddddd", | |||
| 450 | + | }, | |||
| 451 | + | DarkPalette: dto.ColorPalette{ | |||
| 452 | + | Background: "#111111", | |||
| 453 | + | Surface: "#1a1a1a", | |||
| 454 | + | SurfaceVariant: "#2a2a2a", | |||
| 455 | + | Text: "#f5f5f5", | |||
| 456 | + | Muted: "#a0a0a0", | |||
| 457 | + | Accent: "#66aaff", | |||
| 458 | + | Border: "#333333", | |||
| 459 | + | }, | |||
| 460 | + | SansFont: "Inter", | |||
| 461 | + | SerifFont: "Georgia", | |||
| 462 | + | MonospaceFont: "JetBrains Mono", | |||
| 463 | + | DefaultFontSize: 16, | |||
| 464 | + | }, | |||
| 465 | + | }} | |||
| 466 | + | ||||
| 467 | + | app := NewApp(Dependencies{ | |||
| 468 | + | Config: config, | |||
| 469 | + | History: history, | |||
| 470 | + | LocationURI: "dumb://history", | |||
| 471 | + | }) | |||
| 472 | + | ||||
| 473 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 474 | + | assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`) | |||
| 475 | + | assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`) | |||
| 476 | + | assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`) | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||
| 480 | + | t.Parallel() | |||
| 481 | + | ||||
| 482 | + | favorites := &fakeFavoritesService{ | |||
| 483 | + | favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}}, | |||
| 484 | + | folders: []*entity.Folder{{Name: "Read Later"}}, | |||
| 485 | + | tags: []*entity.Tag{{Name: "Go"}}, | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | app := NewApp(Dependencies{ | |||
| 489 | + | Favorites: favorites, | |||
| 490 | + | LocationURI: "dumb://favorites", | |||
| 491 | + | }) | |||
| 492 | + | ||||
| 493 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 494 | + | assert.Equal(t, RouteFavorites, app.CurrentRoute()) | |||
| 495 | + | assert.Len(t, app.favorites, 1) | |||
| 496 | + | assert.Len(t, app.folders, 1) | |||
| 497 | + | assert.Len(t, app.tags, 1) | |||
| 498 | + | assert.Contains(t, app.renderedHTML, "Favorites") | |||
| 499 | + | assert.Contains(t, app.renderedHTML, "Example") | |||
| 500 | + | assert.Contains(t, app.renderedHTML, "Read Later") | |||
| 501 | + | assert.Contains(t, app.renderedHTML, "Go") | |||
| 502 | + | assert.True(t, favorites.calledList) | |||
| 503 | + | assert.True(t, favorites.calledFolders) | |||
| 504 | + | assert.True(t, favorites.calledTags) | |||
| 505 | + | ||||
| 506 | + | // Shell frame present, no full document wrapper. | |||
| 507 | + | assert.NotContains(t, app.renderedHTML, "<html") | |||
| 508 | + | assert.NotContains(t, app.renderedHTML, "<head") | |||
| 509 | + | assert.NotContains(t, app.renderedHTML, "<body") | |||
| 510 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |||
| 511 | + | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | |||
| 512 | + | assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`) | |||
| 513 | + | assert.Contains(t, app.renderedHTML, `sv-section`) | |||
| 514 | + | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) { | |||
| 518 | + | t.Parallel() | |||
| 519 | + | ||||
| 520 | + | folderID := entity.FolderID(1) | |||
| 521 | + | shortcut := 3 | |||
| 522 | + | favorites := &fakeFavoritesService{ | |||
| 523 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}}, | |||
| 524 | + | folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}}, | |||
| 525 | + | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"}) | |||
| 529 | + | ||||
| 530 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 531 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`) | |||
| 532 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`) | |||
| 533 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`) | |||
| 534 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`) | |||
| 535 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`) | |||
| 536 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`) | |||
| 537 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`) | |||
| 538 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`) | |||
| 539 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`) | |||
| 540 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`) | |||
| 541 | + | assert.Contains(t, app.renderedHTML, `name="tags"`) | |||
| 542 | + | assert.Contains(t, app.renderedHTML, `value="7"`) | |||
| 543 | + | assert.Contains(t, app.renderedHTML, "Read Later") | |||
| 544 | + | assert.Contains(t, app.renderedHTML, "Go") | |||
| 545 | + | assert.Contains(t, app.renderedHTML, "Shortcut 3") | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 549 | + | dom := &fakeDOM{} | |||
| 550 | + | favorites := &fakeFavoritesService{ | |||
| 551 | + | favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}}, | |||
| 552 | + | folders: []*entity.Folder{{ID: 5, Name: "Read Later"}}, | |||
| 553 | + | tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}}, | |||
| 554 | + | } | |||
| 555 | + | app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"}) | |||
| 556 | + | ||||
| 557 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 558 | + | Action: favoriteActionCreate, | |||
| 559 | + | Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"}, | |||
| 560 | + | })) | |||
| 561 | + | require.NotNil(t, favorites.createdFavorite.FolderID) | |||
| 562 | + | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | |||
| 563 | + | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | |||
| 564 | + | assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags) | |||
| 565 | + | assert.Contains(t, dom.HTML(), "Added favorite New") | |||
| 566 | + | ||||
| 567 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 568 | + | Action: favoriteActionUpdate, | |||
| 569 | + | Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"}, | |||
| 570 | + | })) | |||
| 571 | + | assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID) | |||
| 572 | + | assert.Equal(t, "Updated", favorites.updatedFavorite.Title) | |||
| 573 | + | require.NotNil(t, favorites.updatedFavorite.ShortcutKey) | |||
| 574 | + | assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey) | |||
| 575 | + | assert.Contains(t, dom.HTML(), "Saved favorite Updated") | |||
| 576 | + | ||||
| 577 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 578 | + | Action: tagActionAssign, | |||
| 579 | + | Data: map[string]string{"favoriteId": "42", "tagId": "7"}, | |||
| 580 | + | })) | |||
| 581 | + | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | |||
| 582 | + | assert.Equal(t, int64(7), favorites.assignedTagID) | |||
| 583 | + | ||||
| 584 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 585 | + | Action: favoriteActionFilterFolder, | |||
| 586 | + | Data: map[string]string{"folderId": "5"}, | |||
| 587 | + | })) | |||
| 588 | + | require.NotNil(t, app.favoriteFolderFilter) | |||
| 589 | + | assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter) | |||
| 590 | + | assert.Nil(t, app.favoriteTagFilter) | |||
| 591 | + | ||||
| 592 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 593 | + | Action: favoriteActionFilterTag, | |||
| 594 | + | Data: map[string]string{"tagId": "7"}, | |||
| 595 | + | })) | |||
| 596 | + | require.NotNil(t, app.favoriteTagFilter) | |||
| 597 | + | assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter) | |||
| 598 | + | assert.Nil(t, app.favoriteFolderFilter) | |||
| 599 | + | ||||
| 600 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 601 | + | Action: folderActionCreate, | |||
| 602 | + | Data: map[string]string{"name": "Reading", "icon": "📚"}, | |||
| 603 | + | })) | |||
| 604 | + | assert.Equal(t, "Reading", favorites.createdFolder) | |||
| 605 | + | assert.Equal(t, "📚", favorites.createdFolderIcon) | |||
| 606 | + | assert.Contains(t, dom.HTML(), "Created folder 📚 Reading") | |||
| 607 | + | ||||
| 608 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 609 | + | Action: favoriteActionDelete, | |||
| 610 | + | Data: map[string]string{"id": "42"}, | |||
| 611 | + | })) | |||
| 612 | + | assert.Equal(t, int64(42), favorites.deletedFavorite) | |||
| 613 | + | assert.Contains(t, dom.HTML(), "Deleted favorite") | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 617 | + | t.Parallel() | |||
| 618 | + | ||||
| 619 | + | config := dto.SystemviewConfigPayload{ | |||
| 620 | + | EngineType: "webkit", | |||
| 621 | + | Appearance: dto.WebUIAppearanceConfig{ | |||
| 622 | + | ColorScheme: "prefer-dark", | |||
| 623 | + | SansFont: "Inter", | |||
| 624 | + | SerifFont: "Georgia", | |||
| 625 | + | MonospaceFont: "JetBrains Mono", | |||
| 626 | + | DefaultFontSize: 16, | |||
| 627 | + | LightPalette: dto.ColorPalette{ | |||
| 628 | + | Background: "#ffffff", | |||
| 629 | + | Surface: "#fafafa", | |||
| 630 | + | SurfaceVariant: "#eeeeee", | |||
| 631 | + | Text: "#111111", | |||
| 632 | + | Muted: "#666666", | |||
| 633 | + | Accent: "#0055ff", | |||
| 634 | + | Border: "#dddddd", | |||
| 635 | + | }, | |||
| 636 | + | DarkPalette: dto.ColorPalette{ | |||
| 637 | + | Background: "#111111", | |||
| 638 | + | Surface: "#1a1a1a", | |||
| 639 | + | SurfaceVariant: "#2a2a2a", | |||
| 640 | + | Text: "#f5f5f5", | |||
| 641 | + | Muted: "#a0a0a0", | |||
| 642 | + | Accent: "#66aaff", | |||
| 643 | + | Border: "#333333", | |||
| 644 | + | }, | |||
| 645 | + | }, | |||
| 646 | + | Performance: dto.SystemviewPerformancePayload{ | |||
| 647 | + | Profile: "balanced", | |||
| 648 | + | Custom: dto.SystemviewCustomPerformancePayload{ | |||
| 649 | + | SkiaCPUThreads: 4, | |||
| 650 | + | SkiaGPUThreads: 2, | |||
| 651 | + | WebProcessMemoryMB: 512, | |||
| 652 | + | NetworkProcessMemoryMB: 128, | |||
| 653 | + | WebViewPoolPrewarm: 1, | |||
| 654 | + | }, | |||
| 655 | + | Hardware: dto.SystemviewHardwarePayload{ | |||
| 656 | + | CPUCores: 8, | |||
| 657 | + | CPUThreads: 16, | |||
| 658 | + | TotalRAMMB: 32768, | |||
| 659 | + | GPUVendor: "NVIDIA", | |||
| 660 | + | GPUName: "RTX 4060", | |||
| 661 | + | VRAMMB: 8192, | |||
| 662 | + | }, | |||
| 663 | + | }, | |||
| 664 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |||
| 665 | + | SearchShortcuts: map[string]dto.SearchShortcut{ | |||
| 666 | + | "ddg": { | |||
| 667 | + | URL: "https://duckduckgo.com/?q=%s", | |||
| 668 | + | Description: "DuckDuckGo", | |||
| 669 | + | }, | |||
| 670 | + | }, | |||
| 671 | + | } | |||
| 672 | + | service := &fakeConfigService{ | |||
| 673 | + | current: config, | |||
| 674 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{ | |||
| 675 | + | { | |||
| 676 | + | Mode: "default", | |||
| 677 | + | DisplayName: "Default", | |||
| 678 | + | Bindings: []port.KeybindingEntry{ | |||
| 679 | + | {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}}, | |||
| 680 | + | {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true}, | |||
| 681 | + | }, | |||
| 682 | + | }, | |||
| 683 | + | { | |||
| 684 | + | Mode: "search", | |||
| 685 | + | DisplayName: "Search", | |||
| 686 | + | Bindings: []port.KeybindingEntry{ | |||
| 687 | + | {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}}, | |||
| 688 | + | }, | |||
| 689 | + | }, | |||
| 690 | + | }}, | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | app := NewApp(Dependencies{ | |||
| 694 | + | Config: service, | |||
| 695 | + | LocationURI: "dumb://config", | |||
| 696 | + | }) | |||
| 697 | + | ||||
| 698 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 699 | + | assert.Equal(t, RouteConfig, app.CurrentRoute()) | |||
| 700 | + | require.NotNil(t, app.config) | |||
| 701 | + | assert.Equal(t, "webkit", app.config.EngineType) | |||
| 702 | + | assert.Contains(t, app.renderedHTML, "webkit") | |||
| 703 | + | assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s") | |||
| 704 | + | assert.Contains(t, app.renderedHTML, "appearance.color_scheme") | |||
| 705 | + | assert.Contains(t, app.renderedHTML, "prefer-dark") | |||
| 706 | + | assert.Contains(t, app.renderedHTML, "appearance.sans_font") | |||
| 707 | + | assert.Contains(t, app.renderedHTML, "Inter") | |||
| 708 | + | assert.Contains(t, app.renderedHTML, "appearance.light_palette.background") | |||
| 709 | + | assert.Contains(t, app.renderedHTML, "#ffffff") | |||
| 710 | + | assert.Contains(t, app.renderedHTML, "performance.profile") | |||
| 711 | + | assert.Contains(t, app.renderedHTML, "balanced") | |||
| 712 | + | assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads") | |||
| 713 | + | assert.Contains(t, app.renderedHTML, "4") | |||
| 714 | + | assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor") | |||
| 715 | + | assert.Contains(t, app.renderedHTML, "NVIDIA") | |||
| 716 | + | assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url") | |||
| 717 | + | assert.Contains(t, app.renderedHTML, "Default") | |||
| 718 | + | assert.Contains(t, app.renderedHTML, "Search") | |||
| 719 | + | assert.Contains(t, app.renderedHTML, "Open") | |||
| 720 | + | assert.Contains(t, app.renderedHTML, "ctrl+o") | |||
| 721 | + | assert.Contains(t, app.renderedHTML, "New tab") | |||
| 722 | + | assert.Contains(t, app.renderedHTML, "ctrl+t") | |||
| 723 | + | assert.Contains(t, app.renderedHTML, "ctrl+shift+t") | |||
| 724 | + | assert.Contains(t, app.renderedHTML, "Find") | |||
| 725 | + | assert.Contains(t, app.renderedHTML, "open") | |||
| 726 | + | assert.Contains(t, app.renderedHTML, "default") | |||
| 727 | + | assert.Contains(t, app.renderedHTML, "custom") | |||
| 728 | + | assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action") | |||
| 729 | + | assert.True(t, service.calledCurrent) | |||
| 730 | + | assert.True(t, service.calledKeybindings) | |||
| 731 | + | ||||
| 732 | + | // Shell frame present, no full document wrapper. | |||
| 733 | + | assert.NotContains(t, app.renderedHTML, "<html") | |||
| 734 | + | assert.NotContains(t, app.renderedHTML, "<head") | |||
| 735 | + | assert.NotContains(t, app.renderedHTML, "<body") | |||
| 736 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |||
| 737 | + | assert.Contains(t, app.renderedHTML, `data-route="config"`) | |||
| 738 | + | assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`) | |||
| 739 | + | assert.Contains(t, app.renderedHTML, `sv-section`) | |||
| 740 | + | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) { | |||
| 744 | + | t.Parallel() | |||
| 745 | + | ||||
| 746 | + | service := &fakeConfigService{ | |||
| 747 | + | current: testConfigPayload(), | |||
| 748 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{ | |||
| 749 | + | Mode: "default", | |||
| 750 | + | DisplayName: "Default", | |||
| 751 | + | Bindings: []port.KeybindingEntry{{ | |||
| 752 | + | Action: "toggle_history_systemview", | |||
| 753 | + | Description: "Toggle history", | |||
| 754 | + | Keys: []string{"ctrl+h"}, | |||
| 755 | + | DefaultKeys: []string{"ctrl+h"}, | |||
| 756 | + | }}, | |||
| 757 | + | }}}, | |||
| 758 | + | } | |||
| 759 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |||
| 760 | + | ||||
| 761 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 762 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`) | |||
| 763 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`) | |||
| 764 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`) | |||
| 765 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`) | |||
| 766 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`) | |||
| 767 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`) | |||
| 768 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`) | |||
| 769 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`) | |||
| 770 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`) | |||
| 771 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`) | |||
| 772 | + | assert.Contains(t, app.renderedHTML, "may require restarting") | |||
| 773 | + | } | |||
| 774 | + | ||||
| 775 | + | func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 776 | + | dom := &fakeDOM{} | |||
| 777 | + | service := &fakeConfigService{ | |||
| 778 | + | current: testConfigPayload(), | |||
| 779 | + | defaultCfg: testDefaultConfigPayload(), | |||
| 780 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{ | |||
| 781 | + | Mode: "default", | |||
| 782 | + | DisplayName: "Default", | |||
| 783 | + | Bindings: []port.KeybindingEntry{{ | |||
| 784 | + | Action: "toggle_history_systemview", | |||
| 785 | + | Description: "Toggle history", | |||
| 786 | + | Keys: []string{"ctrl+h"}, | |||
| 787 | + | DefaultKeys: []string{"ctrl+h"}, | |||
| 788 | + | }}, | |||
| 789 | + | }}}, | |||
| 790 | + | setResp: port.SetKeybindingResponse{}, | |||
| 791 | + | } | |||
| 792 | + | app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"}) | |||
| 793 | + | ||||
| 794 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 795 | + | Action: configActionSaveAppearance, | |||
| 796 | + | Data: map[string]string{ | |||
| 797 | + | "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono", | |||
| 798 | + | "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light", | |||
| 799 | + | "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd", | |||
| 800 | + | "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333", | |||
| 801 | + | }, | |||
| 802 | + | })) | |||
| 803 | + | assert.True(t, service.calledSave) | |||
| 804 | + | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | |||
| 805 | + | assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001) | |||
| 806 | + | assert.Contains(t, dom.HTML(), "Saved appearance settings") | |||
| 807 | + | assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`) | |||
| 808 | + | ||||
| 809 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 810 | + | Action: configActionCreateSearchShortcut, | |||
| 811 | + | Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"}, | |||
| 812 | + | })) | |||
| 813 | + | assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description) | |||
| 814 | + | assert.Contains(t, dom.HTML(), "Created search shortcut g") | |||
| 815 | + | ||||
| 816 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 817 | + | Action: configActionSavePerformance, | |||
| 818 | + | Data: map[string]string{ | |||
| 819 | + | "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2", | |||
| 820 | + | "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4", | |||
| 821 | + | }, | |||
| 822 | + | })) | |||
| 823 | + | assert.Equal(t, "custom", service.savedConfig.Performance.Profile) | |||
| 824 | + | assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB) | |||
| 825 | + | assert.Contains(t, dom.HTML(), "Saved performance settings") | |||
| 826 | + | ||||
| 827 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 828 | + | Action: configActionSavePerformance, | |||
| 829 | + | Data: map[string]string{"profile": "default"}, | |||
| 830 | + | })) | |||
| 831 | + | assert.Equal(t, "default", service.savedConfig.Performance.Profile) | |||
| 832 | + | assert.Contains(t, dom.HTML(), "Saved performance settings") | |||
| 833 | + | ||||
| 834 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 835 | + | Action: configActionSetKeybinding, | |||
| 836 | + | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"}, | |||
| 837 | + | })) | |||
| 838 | + | assert.True(t, service.calledSet) | |||
| 839 | + | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | |||
| 840 | + | assert.Contains(t, service.setReq.RequestID, "systemviews-config-") | |||
| 841 | + | assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview") | |||
| 842 | + | ||||
| 843 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 844 | + | Action: configActionResetKeybinding, | |||
| 845 | + | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"}, | |||
| 846 | + | })) | |||
| 847 | + | assert.True(t, service.calledReset) | |||
| 848 | + | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | |||
| 849 | + | assert.Contains(t, service.resetReq.RequestID, "systemviews-config-") | |||
| 850 | + | assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID) | |||
| 851 | + | assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview") | |||
| 852 | + | ||||
| 853 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | |||
| 854 | + | assert.True(t, service.calledResetAll) | |||
| 855 | + | assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults") | |||
| 856 | + | } | |||
| 857 | + | ||||
| 858 | + | func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) { | |||
| 859 | + | t.Parallel() | |||
| 860 | + | ||||
| 861 | + | service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()} | |||
| 862 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |||
| 863 | + | ||||
| 864 | + | err := app.handleConfigAction(context.Background(), DOMAction{ | |||
| 865 | + | Action: configActionSaveSearch, | |||
| 866 | + | Data: map[string]string{"default_search_engine": "https://example.com/search"}, | |||
| 867 | + | }) | |||
| 868 | + | require.Error(t, err) | |||
| 869 | + | assert.False(t, service.calledSave) | |||
| 870 | + | ||||
| 871 | + | err = app.handleConfigAction(context.Background(), DOMAction{ | |||
| 872 | + | Action: configActionCreateSearchShortcut, | |||
| 873 | + | Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"}, | |||
| 874 | + | }) | |||
| 875 | + | require.Error(t, err) | |||
| 876 | + | assert.False(t, service.calledSave) | |||
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | func testConfigPayload() dto.SystemviewConfigPayload { | |||
| 880 | + | return dto.SystemviewConfigPayload{ | |||
| 881 | + | EngineType: "webkit", | |||
| 882 | + | Appearance: dto.WebUIAppearanceConfig{ | |||
| 883 | + | ColorScheme: "prefer-dark", | |||
| 884 | + | SansFont: "Inter", | |||
| 885 | + | SerifFont: "Georgia", | |||
| 886 | + | MonospaceFont: "JetBrains Mono", | |||
| 887 | + | DefaultFontSize: 16, | |||
| 888 | + | LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"}, | |||
| 889 | + | DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"}, | |||
| 890 | + | }, | |||
| 891 | + | DefaultUIScale: 1, | |||
| 892 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |||
| 893 | + | SearchShortcuts: map[string]dto.SearchShortcut{ | |||
| 894 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}, | |||
| 895 | + | }, | |||
| 896 | + | Performance: dto.SystemviewPerformancePayload{ | |||
| 897 | + | Profile: "default", | |||
| 898 | + | Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2}, | |||
| 899 | + | Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096}, | |||
| 900 | + | }, | |||
| 901 | + | } | |||
| 902 | + | } | |||
| 903 | + | ||||
| 904 | + | func testDefaultConfigPayload() dto.SystemviewConfigPayload { | |||
| 905 | + | cfg := testConfigPayload() | |||
| 906 | + | cfg.Appearance.DefaultFontSize = 14 | |||
| 907 | + | cfg.DefaultUIScale = 1 | |||
| 908 | + | return cfg | |||
| 909 | + | } | |||
| 910 | + | ||||
| 911 | + | // Handwritten fake to capture DOM mounts for stateful render assertions. | |||
| 912 | + | type fakeDOM struct { | |||
| 913 | + | mu sync.Mutex | |||
| 914 | + | mounted bool | |||
| 915 | + | html string | |||
| 916 | + | appendedHTML string | |||
| 917 | + | mounts chan string | |||
| 918 | + | } | |||
| 919 | + | ||||
| 920 | + | func (d *fakeDOM) Mount(markup string) error { | |||
| 921 | + | d.mu.Lock() | |||
| 922 | + | d.mounted = true | |||
| 923 | + | d.html = markup | |||
| 924 | + | mounts := d.mounts | |||
| 925 | + | d.mu.Unlock() | |||
| 926 | + | if mounts != nil { | |||
| 927 | + | select { | |||
| 928 | + | case mounts <- markup: | |||
| 929 | + | default: | |||
| 930 | + | } | |||
| 931 | + | } | |||
| 932 | + | return nil | |||
| 933 | + | } | |||
| 934 | + | ||||
| 935 | + | func (d *fakeDOM) Mounted() bool { | |||
| 936 | + | d.mu.Lock() | |||
| 937 | + | defer d.mu.Unlock() | |||
| 938 | + | return d.mounted | |||
| 939 | + | } | |||
| 940 | + | ||||
| 941 | + | func (d *fakeDOM) HTML() string { | |||
| 942 | + | d.mu.Lock() | |||
| 943 | + | defer d.mu.Unlock() | |||
| 944 | + | return d.html | |||
| 945 | + | } | |||
| 946 | + | ||||
| 947 | + | func (d *fakeDOM) AppendedHTML() string { | |||
| 948 | + | d.mu.Lock() | |||
| 949 | + | defer d.mu.Unlock() | |||
| 950 | + | return d.appendedHTML | |||
| 951 | + | } | |||
| 952 | + | ||||
| 953 | + | func receiveMount(t *testing.T, mounts <-chan string) string { | |||
| 954 | + | t.Helper() | |||
| 955 | + | select { | |||
| 956 | + | case html := <-mounts: | |||
| 957 | + | return html | |||
| 958 | + | case <-time.After(time.Second): | |||
| 959 | + | t.Fatal("timed out waiting for DOM mount") | |||
| 960 | + | return "" | |||
| 961 | + | } | |||
| 962 | + | } | |||
| 963 | + | ||||
| 964 | + | func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) { | |||
| 965 | + | t.Helper() | |||
| 966 | + | select { | |||
| 967 | + | case html := <-mounts: | |||
| 968 | + | t.Fatalf("unexpected DOM mount: %s", html) | |||
| 969 | + | case <-time.After(wait): | |||
| 970 | + | } | |||
| 971 | + | } | |||
| 972 | + | ||||
| 973 | + | type fakeActionDOM struct { | |||
| 974 | + | fakeDOM | |||
| 975 | + | handler DOMActionHandler | |||
| 976 | + | released bool | |||
| 977 | + | } | |||
| 978 | + | ||||
| 979 | + | func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error { | |||
| 980 | + | d.handler = handler | |||
| 981 | + | return nil | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | func (d *fakeActionDOM) AppendHistoryTimeline(markup string) error { | |||
| 985 | + | d.mu.Lock() | |||
| 986 | + | d.appendedHTML += markup | |||
| 987 | + | d.mu.Unlock() | |||
| 988 | + | return nil | |||
| 989 | + | } | |||
| 990 | + | ||||
| 991 | + | func (d *fakeActionDOM) Release() { | |||
| 992 | + | d.released = true | |||
| 993 | + | } | |||
| 994 | + | ||||
| 995 | + | type failingActionDOM struct { | |||
| 996 | + | fakeDOM | |||
| 997 | + | bindErr error | |||
| 998 | + | } | |||
| 999 | + | ||||
| 1000 | + | func (d *failingActionDOM) BindActions(DOMActionHandler) error { | |||
| 1001 | + | return d.bindErr | |||
| 1002 | + | } | |||
| 1003 | + | ||||
| 1004 | + | // Handwritten fake to capture history state for stateful render assertions. | |||
| 1005 | + | type fakeHistoryService struct { | |||
| 1006 | + | called bool | |||
| 1007 | + | limit int | |||
| 1008 | + | offset int | |||
| 1009 | + | entries []*entity.HistoryEntry | |||
| 1010 | + | err error | |||
| 1011 | + | domainCalled bool | |||
| 1012 | + | domain string | |||
| 1013 | + | ||||
| 1014 | + | searchCalled bool | |||
| 1015 | + | query string | |||
| 1016 | + | searchLimit int | |||
| 1017 | + | searchEntries []*entity.HistoryEntry | |||
| 1018 | + | ||||
| 1019 | + | deletedEntryID int64 | |||
| 1020 | + | deletedRangeID string | |||
| 1021 | + | deletedDomain string | |||
| 1022 | + | domainStats []*entity.DomainStat | |||
| 1023 | + | stats *entity.HistoryStats | |||
| 1024 | + | statsCalled bool | |||
| 1025 | + | window *entity.HistoryWindow | |||
| 1026 | + | windowBefore time.Time | |||
| 1027 | + | windowDomain string | |||
| 1028 | + | analyticsCalled bool | |||
| 1029 | + | ||||
| 1030 | + | timelineStarted chan struct{} | |||
| 1031 | + | releaseTimeline chan struct{} | |||
| 1032 | + | startOnce sync.Once | |||
| 1033 | + | } | |||
| 1034 | + | ||||
| 1035 | + | func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) { | |||
| 1036 | + | s.called = true | |||
| 1037 | + | s.limit = limit | |||
| 1038 | + | s.offset = offset | |||
| 1039 | + | if s.timelineStarted != nil { | |||
| 1040 | + | s.startOnce.Do(func() { close(s.timelineStarted) }) | |||
| 1041 | + | } | |||
| 1042 | + | if s.releaseTimeline != nil { | |||
| 1043 | + | <-s.releaseTimeline | |||
| 1044 | + | } | |||
| 1045 | + | return s.entries, s.err | |||
| 1046 | + | } | |||
| 1047 | + | ||||
| 1048 | + | func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) { | |||
| 1049 | + | s.domainCalled = true | |||
| 1050 | + | s.domain = domain | |||
| 1051 | + | s.limit = limit | |||
| 1052 | + | s.offset = offset | |||
| 1053 | + | return s.entries, s.err | |||
| 1054 | + | } | |||
| 1055 | + | ||||
| 1056 | + | func (s *fakeHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) { | |||
| 1057 | + | s.called = true | |||
| 1058 | + | s.windowBefore = before | |||
| 1059 | + | s.windowDomain = domain | |||
| 1060 | + | if s.timelineStarted != nil { | |||
| 1061 | + | s.startOnce.Do(func() { close(s.timelineStarted) }) | |||
| 1062 | + | } | |||
| 1063 | + | if s.releaseTimeline != nil { | |||
| 1064 | + | <-s.releaseTimeline | |||
| 1065 | + | } | |||
| 1066 | + | if s.err != nil { | |||
| 1067 | + | return nil, s.err | |||
| 1068 | + | } | |||
| 1069 | + | if s.window != nil { | |||
| 1070 | + | return s.window, nil | |||
| 1071 | + | } | |||
| 1072 | + | return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil | |||
| 1073 | + | } | |||
| 1074 | + | ||||
| 1075 | + | func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) { | |||
| 1076 | + | s.searchCalled = true | |||
| 1077 | + | s.query = query | |||
| 1078 | + | s.searchLimit = limit | |||
| 1079 | + | if s.searchEntries != nil { | |||
| 1080 | + | return s.searchEntries, nil | |||
| 1081 | + | } | |||
| 1082 | + | return s.entries, nil | |||
| 1083 | + | } | |||
| 1084 | + | ||||
| 1085 | + | func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error { | |||
| 1086 | + | s.deletedEntryID = id | |||
| 1087 | + | return nil | |||
| 1088 | + | } | |||
| 1089 | + | ||||
| 1090 | + | func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error { | |||
| 1091 | + | s.deletedRangeID = rangeID | |||
| 1092 | + | return nil | |||
| 1093 | + | } | |||
| 1094 | + | ||||
| 1095 | + | func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) { | |||
| 1096 | + | s.statsCalled = true | |||
| 1097 | + | return s.stats, nil | |||
| 1098 | + | } | |||
| 1099 | + | ||||
| 1100 | + | func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |||
| 1101 | + | s.analyticsCalled = true | |||
| 1102 | + | return nil, nil | |||
| 1103 | + | } | |||
| 1104 | + | ||||
| 1105 | + | func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | |||
| 1106 | + | return s.domainStats, nil | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error { | |||
| 1110 | + | s.deletedDomain = domain | |||
| 1111 | + | return nil | |||
| 1112 | + | } | |||
| 1113 | + | ||||
| 1114 | + | // Handwritten fake to capture favorites state for stateful render assertions. | |||
| 1115 | + | type fakeFavoritesService struct { | |||
| 1116 | + | calledList bool | |||
| 1117 | + | calledFolders bool | |||
| 1118 | + | calledTags bool | |||
| 1119 | + | favorites []*entity.Favorite | |||
| 1120 | + | folders []*entity.Folder | |||
| 1121 | + | tags []*entity.Tag | |||
| 1122 | + | ||||
| 1123 | + | createdFavorite dto.FavoriteCreateInput | |||
| 1124 | + | updatedFavorite dto.FavoriteUpdateInput | |||
| 1125 | + | deletedFavorite int64 | |||
| 1126 | + | createdFolder string | |||
| 1127 | + | createdFolderIcon string | |||
| 1128 | + | updatedFolderID int64 | |||
| 1129 | + | deletedFolderID int64 | |||
| 1130 | + | createdTag string | |||
| 1131 | + | updatedTagID int64 | |||
| 1132 | + | deletedTagID int64 | |||
| 1133 | + | assignedFavoriteID int64 | |||
| 1134 | + | assignedTagID int64 | |||
| 1135 | + | removedFavoriteID int64 | |||
| 1136 | + | removedTagID int64 | |||
| 1137 | + | } | |||
| 1138 | + | ||||
| 1139 | + | func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) { | |||
| 1140 | + | s.calledList = true | |||
| 1141 | + | return s.favorites, nil | |||
| 1142 | + | } | |||
| 1143 | + | ||||
| 1144 | + | func (s *fakeFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) { | |||
| 1145 | + | s.calledFolders = true | |||
| 1146 | + | return s.folders, nil | |||
| 1147 | + | } | |||
| 1148 | + | ||||
| 1149 | + | func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) { | |||
| 1150 | + | s.calledTags = true | |||
| 1151 | + | return s.tags, nil | |||
| 1152 | + | } | |||
| 1153 | + | ||||
| 1154 | + | func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) { | |||
| 1155 | + | s.createdFavorite = input | |||
| 1156 | + | return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil | |||
| 1157 | + | } | |||
| 1158 | + | ||||
| 1159 | + | func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) { | |||
| 1160 | + | s.updatedFavorite = input | |||
| 1161 | + | return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil | |||
| 1162 | + | } | |||
| 1163 | + | ||||
| 1164 | + | func (s *fakeFavoritesService) DeleteFavorite(_ context.Context, id int64) error { | |||
| 1165 | + | s.deletedFavorite = id | |||
| 1166 | + | return nil | |||
| 1167 | + | } | |||
| 1168 | + | ||||
| 1169 | + | func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil } | |||
| 1170 | + | ||||
| 1171 | + | func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil } | |||
| 1172 | + | ||||
| 1173 | + | func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) { | |||
| 1174 | + | s.createdFolder = name | |||
| 1175 | + | s.createdFolderIcon = icon | |||
| 1176 | + | return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil | |||
| 1177 | + | } | |||
| 1178 | + | ||||
| 1179 | + | func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error { | |||
| 1180 | + | s.updatedFolderID = id | |||
| 1181 | + | return nil | |||
| 1182 | + | } | |||
| 1183 | + | ||||
| 1184 | + | func (s *fakeFavoritesService) DeleteFolder(_ context.Context, id int64) error { | |||
| 1185 | + | s.deletedFolderID = id | |||
| 1186 | + | return nil | |||
| 1187 | + | } | |||
| 1188 | + | ||||
| 1189 | + | func (s *fakeFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) { | |||
| 1190 | + | s.createdTag = name | |||
| 1191 | + | return &entity.Tag{ID: 55, Name: name, Color: color}, nil | |||
| 1192 | + | } | |||
| 1193 | + | ||||
| 1194 | + | func (s *fakeFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error { | |||
| 1195 | + | s.updatedTagID = id | |||
| 1196 | + | return nil | |||
| 1197 | + | } | |||
| 1198 | + | ||||
| 1199 | + | func (s *fakeFavoritesService) DeleteTag(_ context.Context, id int64) error { | |||
| 1200 | + | s.deletedTagID = id | |||
| 1201 | + | return nil | |||
| 1202 | + | } | |||
| 1203 | + | ||||
| 1204 | + | func (s *fakeFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error { | |||
| 1205 | + | s.assignedFavoriteID = favoriteID | |||
| 1206 | + | s.assignedTagID = tagID | |||
| 1207 | + | return nil | |||
| 1208 | + | } | |||
| 1209 | + | ||||
| 1210 | + | func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error { | |||
| 1211 | + | s.removedFavoriteID = favoriteID | |||
| 1212 | + | s.removedTagID = tagID | |||
| 1213 | + | return nil | |||
| 1214 | + | } | |||
| 1215 | + | ||||
| 1216 | + | // Handwritten fake to capture config state for stateful render assertions. | |||
| 1217 | + | type fakeConfigService struct { | |||
| 1218 | + | calledCurrent bool | |||
| 1219 | + | calledDefault bool | |||
| 1220 | + | calledSave bool | |||
| 1221 | + | calledKeybindings bool | |||
| 1222 | + | calledSet bool | |||
| 1223 | + | calledReset bool | |||
| 1224 | + | calledResetAll bool | |||
| 1225 | + | ||||
| 1226 | + | current dto.SystemviewConfigPayload | |||
| 1227 | + | defaultCfg dto.SystemviewConfigPayload | |||
| 1228 | + | keybindings port.KeybindingsConfig | |||
| 1229 | + | savedConfig dto.WebUIConfig | |||
| 1230 | + | setReq port.SetKeybindingRequest | |||
| 1231 | + | setResp port.SetKeybindingResponse | |||
| 1232 | + | resetReq port.ResetKeybindingRequest | |||
| 1233 | + | } | |||
| 1234 | + | ||||
| 1235 | + | func (s *fakeConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 1236 | + | s.calledCurrent = true | |||
| 1237 | + | return s.current, nil | |||
| 1238 | + | } | |||
| 1239 | + | ||||
| 1240 | + | func (s *fakeConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) { | |||
| 1241 | + | s.calledDefault = true | |||
| 1242 | + | return s.defaultCfg, nil | |||
| 1243 | + | } | |||
| 1244 | + | ||||
| 1245 | + | func (s *fakeConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error { | |||
| 1246 | + | s.calledSave = true | |||
| 1247 | + | s.savedConfig = cfg | |||
| 1248 | + | s.current.Appearance = cfg.Appearance | |||
| 1249 | + | s.current.DefaultUIScale = cfg.DefaultUIScale | |||
| 1250 | + | s.current.DefaultSearchEngine = cfg.DefaultSearchEngine | |||
| 1251 | + | s.current.SearchShortcuts = cfg.SearchShortcuts | |||
| 1252 | + | s.current.Performance.Profile = cfg.Performance.Profile | |||
| 1253 | + | s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{ | |||
| 1254 | + | SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads, | |||
| 1255 | + | SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads, | |||
| 1256 | + | WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB, | |||
| 1257 | + | NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB, | |||
| 1258 | + | WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm, | |||
| 1259 | + | } | |||
| 1260 | + | return nil | |||
| 1261 | + | } | |||
| 1262 | + | ||||
| 1263 | + | func (s *fakeConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) { | |||
| 1264 | + | s.calledKeybindings = true | |||
| 1265 | + | return s.keybindings, nil | |||
| 1266 | + | } | |||
| 1267 | + | ||||
| 1268 | + | func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) { | |||
| 1269 | + | s.calledSet = true | |||
| 1270 | + | s.setReq = req | |||
| 1271 | + | return s.setResp, nil | |||
| 1272 | + | } | |||
| 1273 | + | ||||
| 1274 | + | func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error { | |||
| 1275 | + | s.calledReset = true | |||
| 1276 | + | s.resetReq = req | |||
| 1277 | + | return nil | |||
| 1278 | + | } | |||
| 1279 | + | ||||
| 1280 | + | func (s *fakeConfigService) ResetAllKeybindings(context.Context) error { | |||
| 1281 | + | s.calledResetAll = true | |||
| 1282 | + | return nil | |||
| 1283 | + | } | |||
| 1284 | + | ||||
| 1285 | + | func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) { | |||
| 1286 | + | app := NewApp(Dependencies{Favorites: &fakeFavoritesService{}}) | |||
| 1287 | + | ||||
| 1288 | + | err := app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1289 | + | Action: favoriteActionCreate, | |||
| 1290 | + | Data: map[string]string{"url": " ", "title": "Blank"}, | |||
| 1291 | + | }) | |||
| 1292 | + | require.Error(t, err) | |||
| 1293 | + | assert.Contains(t, err.Error(), "favorite URL is required") | |||
| 1294 | + | ||||
| 1295 | + | err = app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1296 | + | Action: favoriteActionCreate, | |||
| 1297 | + | Data: map[string]string{"url": "example.com", "title": "Relative"}, | |||
| 1298 | + | }) | |||
| 1299 | + | require.Error(t, err) | |||
| 1300 | + | assert.Contains(t, err.Error(), "favorite URL must be absolute") | |||
| 1301 | + | ||||
| 1302 | + | err = app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1303 | + | Action: favoriteActionCreate, | |||
| 1304 | + | Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"}, | |||
| 1305 | + | }) | |||
| 1306 | + | require.Error(t, err) | |||
| 1307 | + | assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme") | |||
| 1308 | + | } | |||
| 1309 | + | ||||
| 1310 | + | func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |||
| 1311 | + | favorites := &fakeFavoritesService{} | |||
| 1312 | + | app := NewApp(Dependencies{Favorites: favorites}) | |||
| 1313 | + | ||||
| 1314 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1315 | + | Action: favoriteActionCreate, | |||
| 1316 | + | Data: map[string]string{"url": "dumb:history", "title": "History"}, | |||
| 1317 | + | })) | |||
| 1318 | + | assert.Equal(t, "dumb:history", favorites.createdFavorite.URL) | |||
| 1319 | + | ||||
| 1320 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1321 | + | Action: favoriteActionCreate, | |||
| 1322 | + | Data: map[string]string{"url": "dumb://config", "title": "Config"}, | |||
| 1323 | + | })) | |||
| 1324 | + | assert.Equal(t, "dumb://config", favorites.createdFavorite.URL) | |||
| 1325 | + | } | |||
| 1326 | + | ||||
| 1327 | + | func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) { | |||
| 1328 | + | favorites := &fakeFavoritesService{} | |||
| 1329 | + | app := NewApp(Dependencies{Favorites: favorites}) | |||
| 1330 | + | ||||
| 1331 | + | err := app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1332 | + | Action: favoriteActionCreate, | |||
| 1333 | + | Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"}, | |||
| 1334 | + | }) | |||
| 1335 | + | ||||
| 1336 | + | require.Error(t, err) | |||
| 1337 | + | assert.Contains(t, err.Error(), "known systemview route") | |||
| 1338 | + | } | |||
| 1339 | + | ||||
| 1340 | + | func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) { | |||
| 1341 | + | _, err := parsePositiveInt64("abc", "tag id") | |||
| 1342 | + | require.Error(t, err) | |||
| 1343 | + | assert.Contains(t, err.Error(), "invalid tag id") | |||
| 1344 | + | assert.Contains(t, err.Error(), "invalid syntax") | |||
| 1345 | + | ||||
| 1346 | + | _, err = parsePositiveInt64("0", "tag id") | |||
| 1347 | + | require.Error(t, err) | |||
| 1348 | + | assert.Contains(t, err.Error(), "tag id must be a positive integer") | |||
| 1349 | + | } | |||
| 1350 | + | ||||
| 1351 | + | func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) { | |||
| 1352 | + | favorites := &fakeFavoritesService{} | |||
| 1353 | + | app := NewApp(Dependencies{Favorites: favorites}) | |||
| 1354 | + | ||||
| 1355 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1356 | + | Action: tagActionAssign, | |||
| 1357 | + | Data: map[string]string{"favorite_id": "42", "tag_id": "7"}, | |||
| 1358 | + | })) | |||
| 1359 | + | assert.Equal(t, int64(42), favorites.assignedFavoriteID) | |||
| 1360 | + | assert.Equal(t, int64(7), favorites.assignedTagID) | |||
| 1361 | + | ||||
| 1362 | + | require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{ | |||
| 1363 | + | Action: tagActionRemove, | |||
| 1364 | + | Data: map[string]string{"favorite_id": "42", "tag_id": "7"}, | |||
| 1365 | + | })) | |||
| 1366 | + | assert.Equal(t, int64(42), favorites.removedFavoriteID) | |||
| 1367 | + | assert.Equal(t, int64(7), favorites.removedTagID) | |||
| 1368 | + | } | |||
| 1369 | + | ||||
| 1370 | + | func TestHandleHistorySearchClearsDomainFilter(t *testing.T) { | |||
| 1371 | + | dom := &fakeDOM{} | |||
| 1372 | + | history := &fakeHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}} | |||
| 1373 | + | app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"}) | |||
| 1374 | + | app.historyDomainFilter = "example.com" | |||
| 1375 | + | ||||
| 1376 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 1377 | + | Action: historyActionSearch, | |||
| 1378 | + | Data: map[string]string{"query": "search"}, | |||
| 1379 | + | })) | |||
| 1380 | + | ||||
| 1381 | + | assert.Equal(t, "search", app.historyQuery) | |||
| 1382 | + | assert.Empty(t, app.historyDomainFilter) | |||
| 1383 | + | assert.True(t, history.searchCalled) | |||
| 1384 | + | assert.False(t, history.domainCalled) | |||
| 1385 | + | } | |||
| 1386 | + | ||||
| 1387 | + | func TestAppRunWithContextRejectsNilContext(t *testing.T) { | |||
| 1388 | + | app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"}) | |||
| 1389 | + | ||||
| 1390 | + | var ctx context.Context | |||
| 1391 | + | err := app.RunWithContext(ctx) | |||
| 1392 | + | ||||
| 1393 | + | require.ErrorContains(t, err, "context is nil") | |||
| 1394 | + | } | |||
| 1395 | + | ||||
| 1396 | + | func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) { | |||
| 1397 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}} | |||
| 1398 | + | app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"}) | |||
| 1399 | + | ||||
| 1400 | + | err := app.RunWithContext(context.Background()) | |||
| 1401 | + | ||||
| 1402 | + | require.ErrorContains(t, err, "DOM not configured") | |||
| 1403 | + | assert.False(t, history.called) | |||
| 1404 | + | } | |||
| 1405 | + | ||||
| 1406 | + | func TestHandleActionsRejectUnknownAction(t *testing.T) { | |||
| 1407 | + | app := NewApp(Dependencies{ | |||
| 1408 | + | History: &fakeHistoryService{}, | |||
| 1409 | + | Favorites: &fakeFavoritesService{}, | |||
| 1410 | + | Config: &fakeConfigService{current: testConfigPayload()}, | |||
| 1411 | + | }) | |||
| 1412 | + | ||||
| 1413 | + | require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action") | |||
| 1414 | + | require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action") | |||
| 1415 | + | require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action") | |||
| 1416 | + | } | |||
| 1417 | + | ||||
| 1418 | + | func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) { | |||
| 1419 | + | service := &fakeConfigService{current: testConfigPayload()} | |||
| 1420 | + | app := NewApp(Dependencies{Config: service}) | |||
| 1421 | + | ||||
| 1422 | + | err := app.handleConfigAction(context.Background(), DOMAction{ | |||
| 1423 | + | Action: configActionSavePerformance, | |||
| 1424 | + | Data: map[string]string{ | |||
| 1425 | + | "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2", | |||
| 1426 | + | "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4", | |||
| 1427 | + | }, | |||
| 1428 | + | }) | |||
| 1429 | + | ||||
| 1430 | + | require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8") | |||
| 1431 | + | assert.False(t, service.calledSave) | |||
| 1432 | + | } | |||
A internal/ui/systemviews/components.templ +96 -0
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) { | |||
| 4 | + | <div class={ appRootClass(theme) } data-route={ string(page.route) } data-page-title={ pageDocumentTitle(page) } | |||
| 5 | + | if theme.InlineVars != "" { | |||
| 6 | + | style={ theme.InlineVars } | |||
| 7 | + | } | |||
| 8 | + | > | |||
| 9 | + | <div class="sv-shell"> | |||
| 10 | + | if page.subtitle != "" { | |||
| 11 | + | <h1 class="sv-title"> | |||
| 12 | + | { page.subtitle } | |||
| 13 | + | if page.subtitleDetail != "" { | |||
| 14 | + | <span class="sv-title-detail">{ page.subtitleDetail }</span> | |||
| 15 | + | } | |||
| 16 | + | </h1> | |||
| 17 | + | } | |||
| 18 | + | <div class="sv-content"> | |||
| 19 | + | @body | |||
| 20 | + | </div> | |||
| 21 | + | </div> | |||
| 22 | + | </div> | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | templ Section(className string, title string) { | |||
| 26 | + | <section class={ sectionClass(className) }> | |||
| 27 | + | <h2>{ title }</h2> | |||
| 28 | + | { children... } | |||
| 29 | + | </section> | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | templ Meta(text string) { | |||
| 33 | + | <p class="sv-meta">{ text }</p> | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | templ EmptyState(message string) { | |||
| 37 | + | <div class="sv-empty"><p>{ message }</p></div> | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | templ LoadingPanel(message string) { | |||
| 41 | + | <div class="sv-loading-panel" role="status" aria-live="polite"> | |||
| 42 | + | <span class="sv-spinner" aria-hidden="true"></span> | |||
| 43 | + | <span>{ message }</span> | |||
| 44 | + | </div> | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | templ LoadingCard(label string) { | |||
| 48 | + | <div class="sv-stat-card sv-loading-card" role="status" aria-live="polite"> | |||
| 49 | + | <span class="sv-spinner" aria-hidden="true"></span> | |||
| 50 | + | <span class="sv-stat-label">{ label }</span> | |||
| 51 | + | </div> | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | templ Alert(kind string, message string) { | |||
| 55 | + | if message != "" { | |||
| 56 | + | <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div> | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | templ List() { | |||
| 61 | + | <ul class="sv-list"> | |||
| 62 | + | { children... } | |||
| 63 | + | </ul> | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | templ ListRow() { | |||
| 67 | + | <li class="sv-list-row"> | |||
| 68 | + | { children... } | |||
| 69 | + | </li> | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | templ Button(label string, action string, classes string, attrs templ.Attributes, disabled bool) { | |||
| 73 | + | <button type="button" class={ buttonClass(classes) } data-sv-action={ action } disabled?={ disabled } { attrs... }>{ label }</button> | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | templ KeyList(keys []string) { | |||
| 77 | + | if len(keys) == 0 { | |||
| 78 | + | <span class="sv-key sv-key-empty">Unassigned</span> | |||
| 79 | + | } else { | |||
| 80 | + | for _, key := range keys { | |||
| 81 | + | <span class="sv-key">{ key }</span> | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | templ KV(rows []kvPair) { | |||
| 87 | + | if len(rows) == 0 { | |||
| 88 | + | @EmptyState("No configuration details") | |||
| 89 | + | } else { | |||
| 90 | + | <dl class="sv-kv"> | |||
| 91 | + | for _, row := range rows { | |||
| 92 | + | <div class="sv-kv-row"><dt>{ row.Label }</dt><dd>{ row.Value }</dd></div> | |||
| 93 | + | } | |||
| 94 | + | </dl> | |||
| 95 | + | } | |||
| 96 | + | } | |||
A internal/ui/systemviews/components_templ.go +755 -0 generated file: collapsed
A internal/ui/systemviews/config.templ +198 -0
| @@ -0,0 +1,198 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 5 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | templ ConfigView(data configRenderData) { | |||
| 9 | + | @Alert("success", data.Notice) | |||
| 10 | + | @Alert("error", data.Error) | |||
| 11 | + | @Section("sv-config-appearance", "Appearance") { | |||
| 12 | + | @AppearanceForm(data.Config) | |||
| 13 | + | } | |||
| 14 | + | @Section("sv-config-search", "Search") { | |||
| 15 | + | @SearchSettings(data.Config) | |||
| 16 | + | } | |||
| 17 | + | @Section("sv-config-performance", "Performance") { | |||
| 18 | + | @PerformanceSettings(data.Config) | |||
| 19 | + | } | |||
| 20 | + | @Section("sv-config-keybindings", "Keybindings") { | |||
| 21 | + | @Meta(configKeybindingSummary(data.Keybindings)) | |||
| 22 | + | @ConfigKeybindings(data.Keybindings) | |||
| 23 | + | } | |||
| 24 | + | @Section("sv-config-values", "Current payload") { | |||
| 25 | + | @KV(flattenValues(data.Config)) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | templ AppearanceForm(config dto.SystemviewConfigPayload) { | |||
| 30 | + | <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save"> | |||
| 31 | + | <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label> | |||
| 32 | + | <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label> | |||
| 33 | + | <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label> | |||
| 34 | + | <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label> | |||
| 35 | + | <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label> | |||
| 36 | + | <label><span>Color scheme</span><select name="color_scheme"> | |||
| 37 | + | <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option> | |||
| 38 | + | <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option> | |||
| 39 | + | <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option> | |||
| 40 | + | </select></label> | |||
| 41 | + | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | |||
| 42 | + | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | |||
| 43 | + | <div class="sv-button-row sv-form-actions"> | |||
| 44 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save appearance")</button> | |||
| 45 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset appearance")</button> | |||
| 46 | + | </div> | |||
| 47 | + | </form> | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | templ PaletteFields(title string, prefix string, palette dto.ColorPalette) { | |||
| 51 | + | <fieldset class="sv-fieldset"> | |||
| 52 | + | <legend>{ title }</legend> | |||
| 53 | + | <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label> | |||
| 54 | + | <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label> | |||
| 55 | + | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label> | |||
| 56 | + | <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label> | |||
| 57 | + | <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label> | |||
| 58 | + | <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label> | |||
| 59 | + | <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label> | |||
| 60 | + | </fieldset> | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | templ SearchSettings(config dto.SystemviewConfigPayload) { | |||
| 64 | + | <form class="sv-card-form" data-sv-action="config.search.save"> | |||
| 65 | + | <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label> | |||
| 66 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save search default")</button> | |||
| 67 | + | </form> | |||
| 68 | + | <div class="sv-group"> | |||
| 69 | + | <div class="sv-group-header"><h4>Search shortcuts</h4></div> | |||
| 70 | + | <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p> | |||
| 71 | + | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | |||
| 72 | + | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | |||
| 73 | + | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label> | |||
| 74 | + | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label> | |||
| 75 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconPlus, "Add shortcut")</button> | |||
| 76 | + | </form> | |||
| 77 | + | {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }} | |||
| 78 | + | if len(shortcuts) == 0 { | |||
| 79 | + | @EmptyState("No search shortcuts configured") | |||
| 80 | + | } else { | |||
| 81 | + | <ul class="sv-list"> | |||
| 82 | + | for _, shortcut := range shortcuts { | |||
| 83 | + | @SearchShortcutItem(shortcut) | |||
| 84 | + | } | |||
| 85 | + | </ul> | |||
| 86 | + | } | |||
| 87 | + | </div> | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | templ SearchShortcutItem(shortcut searchShortcutRow) { | |||
| 91 | + | <li class="sv-list-row"> | |||
| 92 | + | <form class="sv-inline-form" data-sv-action="config.searchShortcut.update"> | |||
| 93 | + | <input type="hidden" name="key" value={ shortcut.Key }/> | |||
| 94 | + | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | |||
| 95 | + | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label> | |||
| 96 | + | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label> | |||
| 97 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |||
| 98 | + | <button class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?" aria-label={ "Delete search shortcut " + shortcut.Key }>@IconOnly(iconTrash, "Delete search shortcut " + shortcut.Key)</button> | |||
| 99 | + | </form> | |||
| 100 | + | </li> | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | templ PerformanceSettings(config dto.SystemviewConfigPayload) { | |||
| 104 | + | {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }} | |||
| 105 | + | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form> | |||
| 106 | + | <label><span>Profile</span><select name="profile" data-sv-performance-profile> | |||
| 107 | + | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | |||
| 108 | + | <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option> | |||
| 109 | + | <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option> | |||
| 110 | + | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | |||
| 111 | + | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | |||
| 112 | + | </select></label> | |||
| 113 | + | <p class="sv-meta">Choose Custom to edit individual performance values.</p> | |||
| 114 | + | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label> | |||
| 115 | + | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label> | |||
| 116 | + | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label> | |||
| 117 | + | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label> | |||
| 118 | + | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile } data-sv-performance-custom/></label> | |||
| 119 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button> | |||
| 120 | + | </form> | |||
| 121 | + | <div class="sv-stat-grid"> | |||
| 122 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div> | |||
| 123 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div> | |||
| 124 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div> | |||
| 125 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div> | |||
| 126 | + | </div> | |||
| 127 | + | if !engineIsWebKit(config.EngineType) { | |||
| 128 | + | <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p> | |||
| 129 | + | } else { | |||
| 130 | + | <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p> | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | templ ConfigKeybindings(keybindings port.KeybindingsConfig) { | |||
| 135 | + | {{ groups := configKeybindingGroups(keybindings) }} | |||
| 136 | + | if groups == nil { | |||
| 137 | + | @EmptyState("Keybindings unavailable") | |||
| 138 | + | } else if len(groups) == 0 { | |||
| 139 | + | @EmptyState("No keybindings configured") | |||
| 140 | + | } else { | |||
| 141 | + | <div class="sv-button-row"> | |||
| 142 | + | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset all keybindings")</button> | |||
| 143 | + | </div> | |||
| 144 | + | for _, group := range groups { | |||
| 145 | + | @ConfigKeybindingGroup(group) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | templ ConfigKeybindingGroup(group renderedKeybindingGroup) { | |||
| 151 | + | <div class="sv-group"> | |||
| 152 | + | <div class="sv-group-header"> | |||
| 153 | + | <div> | |||
| 154 | + | <h4>{ keybindingGroupTitle(group) }</h4> | |||
| 155 | + | if keybindingGroupMeta(group) != "" { | |||
| 156 | + | <p class="sv-meta">{ keybindingGroupMeta(group) }</p> | |||
| 157 | + | } | |||
| 158 | + | </div> | |||
| 159 | + | </div> | |||
| 160 | + | <div class="sv-group-body"> | |||
| 161 | + | if len(group.Bindings) == 0 { | |||
| 162 | + | @EmptyState("No bindings in this group") | |||
| 163 | + | } else { | |||
| 164 | + | <ul class="sv-list"> | |||
| 165 | + | for _, binding := range group.Bindings { | |||
| 166 | + | @ConfigKeybinding(group, binding) | |||
| 167 | + | } | |||
| 168 | + | </ul> | |||
| 169 | + | } | |||
| 170 | + | </div> | |||
| 171 | + | </div> | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) { | |||
| 175 | + | <li class="sv-list-row"> | |||
| 176 | + | <div class="sv-keybinding-row"> | |||
| 177 | + | <div> | |||
| 178 | + | <strong>{ keybindingDescription(binding) }</strong> | |||
| 179 | + | <p class="sv-meta">{ keybindingAction(binding) }</p> | |||
| 180 | + | <div class="sv-binding-keys"> | |||
| 181 | + | @KeyList(binding.Keys) | |||
| 182 | + | <span class="sv-badge">{ keybindingStatus(binding) }</span> | |||
| 183 | + | if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 { | |||
| 184 | + | <span class="sv-meta">default:</span> | |||
| 185 | + | @KeyList(binding.DefaultKeys) | |||
| 186 | + | } | |||
| 187 | + | </div> | |||
| 188 | + | </div> | |||
| 189 | + | <form class="sv-inline-form" data-sv-action="config.keybinding.set"> | |||
| 190 | + | <input type="hidden" name="mode" value={ group.Mode }/> | |||
| 191 | + | <input type="hidden" name="action" value={ binding.Action }/> | |||
| 192 | + | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label> | |||
| 193 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |||
| 194 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>@ButtonIconLabel(iconRotateCCW, "Reset")</button> | |||
| 195 | + | </form> | |||
| 196 | + | </div> | |||
| 197 | + | </li> | |||
| 198 | + | } | |||
A internal/ui/systemviews/config_actions.go +493 -0
| @@ -0,0 +1,493 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "math" | |||
| 7 | + | "net/url" | |||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync/atomic" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | configActionSaveAppearance = "config.appearance.save" | |||
| 18 | + | configActionResetAppearance = "config.appearance.reset" | |||
| 19 | + | configActionSaveSearch = "config.search.save" | |||
| 20 | + | configActionCreateSearchShortcut = "config.searchShortcut.create" | |||
| 21 | + | configActionUpdateSearchShortcut = "config.searchShortcut.update" | |||
| 22 | + | configActionDeleteSearchShortcut = "config.searchShortcut.delete" | |||
| 23 | + | configActionSavePerformance = "config.performance.save" | |||
| 24 | + | configActionSetKeybinding = "config.keybinding.set" | |||
| 25 | + | configActionResetKeybinding = "config.keybinding.reset" | |||
| 26 | + | configActionResetAllKeybindings = "config.keybinding.resetAll" | |||
| 27 | + | configRequestIDPrefix = "systemviews-config" | |||
| 28 | + | balancedPerformanceProfile = "balanced" | |||
| 29 | + | customPerformanceProfile = "custom" | |||
| 30 | + | httpScheme = "http" | |||
| 31 | + | maxCustomPerformanceThreads = 8 | |||
| 32 | + | maxCustomPerformanceWebMemoryMB = 16384 | |||
| 33 | + | maxCustomPerformanceNetworkMemoryMB = 4096 | |||
| 34 | + | maxCustomPerformancePrewarm = 20 | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | var configRequestCounter atomic.Uint64 | |||
| 38 | + | ||||
| 39 | + | func nextConfigRequestID() string { | |||
| 40 | + | return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1)) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | |||
| 44 | + | if a.deps.Config == nil { | |||
| 45 | + | return fmt.Errorf("config service not configured") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | switch event.Action { | |||
| 49 | + | case configActionSaveAppearance: | |||
| 50 | + | return a.saveAppearanceConfig(ctx, event.Data) | |||
| 51 | + | case configActionResetAppearance: | |||
| 52 | + | return a.resetAppearanceConfig(ctx) | |||
| 53 | + | case configActionSaveSearch: | |||
| 54 | + | return a.saveSearchConfig(ctx, event.Data) | |||
| 55 | + | case configActionCreateSearchShortcut: | |||
| 56 | + | return a.createSearchShortcut(ctx, event.Data) | |||
| 57 | + | case configActionUpdateSearchShortcut: | |||
| 58 | + | return a.updateSearchShortcut(ctx, event.Data) | |||
| 59 | + | case configActionDeleteSearchShortcut: | |||
| 60 | + | return a.deleteSearchShortcut(ctx, event.Data) | |||
| 61 | + | case configActionSavePerformance: | |||
| 62 | + | return a.savePerformanceConfig(ctx, event.Data) | |||
| 63 | + | case configActionSetKeybinding: | |||
| 64 | + | return a.setConfigKeybinding(ctx, event.Data) | |||
| 65 | + | case configActionResetKeybinding: | |||
| 66 | + | return a.resetConfigKeybinding(ctx, event.Data) | |||
| 67 | + | case configActionResetAllKeybindings: | |||
| 68 | + | if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | a.configNotice = "Reset all keybindings to defaults" | |||
| 72 | + | return nil | |||
| 73 | + | default: | |||
| 74 | + | return fmt.Errorf("unknown config action: %q", event.Action) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error { | |||
| 79 | + | cfg, err := a.editableConfig(ctx) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | fontSize, err := parseConfigInt(data["default_font_size"], "default font size") | |||
| 85 | + | if err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale") | |||
| 89 | + | if err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | if fontSize <= 0 { | |||
| 93 | + | return fmt.Errorf("default font size must be positive") | |||
| 94 | + | } | |||
| 95 | + | if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) { | |||
| 96 | + | return fmt.Errorf("UI scale must be a finite positive number") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"]) | |||
| 100 | + | cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"]) | |||
| 101 | + | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | |||
| 102 | + | cfg.Appearance.DefaultFontSize = fontSize | |||
| 103 | + | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | |||
| 104 | + | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | |||
| 105 | + | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | |||
| 106 | + | cfg.DefaultUIScale = uiScale | |||
| 107 | + | ||||
| 108 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | a.configNotice = "Saved appearance settings" | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (a *App) resetAppearanceConfig(ctx context.Context) error { | |||
| 116 | + | cfg, err := a.editableConfig(ctx) | |||
| 117 | + | if err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | defaults, err := a.deps.Config.Default(ctx) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | cfg.Appearance = defaults.Appearance | |||
| 125 | + | cfg.DefaultUIScale = defaults.DefaultUIScale | |||
| 126 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | a.configNotice = "Reset appearance settings to defaults" | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error { | |||
| 134 | + | cfg, err := a.editableConfig(ctx) | |||
| 135 | + | if err != nil { | |||
| 136 | + | return err | |||
| 137 | + | } | |||
| 138 | + | defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine") | |||
| 139 | + | if err != nil { | |||
| 140 | + | return err | |||
| 141 | + | } | |||
| 142 | + | cfg.DefaultSearchEngine = defaultSearchEngine | |||
| 143 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | a.configNotice = "Saved default search engine" | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error { | |||
| 151 | + | cfg, err := a.editableConfig(ctx) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return err | |||
| 154 | + | } | |||
| 155 | + | key, shortcut := searchShortcutFromForm(data, "key") | |||
| 156 | + | if key == "" { | |||
| 157 | + | return fmt.Errorf("search shortcut key is required") | |||
| 158 | + | } | |||
| 159 | + | if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil { | |||
| 160 | + | return err | |||
| 161 | + | } | |||
| 162 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |||
| 163 | + | if _, exists := cfg.SearchShortcuts[key]; exists { | |||
| 164 | + | return fmt.Errorf("search shortcut %q already exists", key) | |||
| 165 | + | } | |||
| 166 | + | cfg.SearchShortcuts[key] = shortcut | |||
| 167 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 168 | + | return err | |||
| 169 | + | } | |||
| 170 | + | a.configNotice = "Created search shortcut " + key | |||
| 171 | + | return nil | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error { | |||
| 175 | + | cfg, err := a.editableConfig(ctx) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return err | |||
| 178 | + | } | |||
| 179 | + | oldKey := strings.TrimSpace(data["key"]) | |||
| 180 | + | if oldKey == "" { | |||
| 181 | + | return fmt.Errorf("search shortcut key is required") | |||
| 182 | + | } | |||
| 183 | + | newKey, shortcut := searchShortcutFromForm(data, "new_key") | |||
| 184 | + | if newKey == "" { | |||
| 185 | + | return fmt.Errorf("new search shortcut key is required") | |||
| 186 | + | } | |||
| 187 | + | if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil { | |||
| 188 | + | return err | |||
| 189 | + | } | |||
| 190 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |||
| 191 | + | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | |||
| 192 | + | return fmt.Errorf("search shortcut %q not found", oldKey) | |||
| 193 | + | } | |||
| 194 | + | if newKey != oldKey { | |||
| 195 | + | if _, exists := cfg.SearchShortcuts[newKey]; exists { | |||
| 196 | + | return fmt.Errorf("search shortcut %q already exists", newKey) | |||
| 197 | + | } | |||
| 198 | + | delete(cfg.SearchShortcuts, oldKey) | |||
| 199 | + | } | |||
| 200 | + | cfg.SearchShortcuts[newKey] = shortcut | |||
| 201 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | a.configNotice = "Saved search shortcut " + newKey | |||
| 205 | + | return nil | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error { | |||
| 209 | + | cfg, err := a.editableConfig(ctx) | |||
| 210 | + | if err != nil { | |||
| 211 | + | return err | |||
| 212 | + | } | |||
| 213 | + | key := strings.TrimSpace(data["key"]) | |||
| 214 | + | if key == "" { | |||
| 215 | + | return fmt.Errorf("search shortcut key is required") | |||
| 216 | + | } | |||
| 217 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |||
| 218 | + | if _, exists := cfg.SearchShortcuts[key]; !exists { | |||
| 219 | + | return fmt.Errorf("search shortcut %q not found", key) | |||
| 220 | + | } | |||
| 221 | + | delete(cfg.SearchShortcuts, key) | |||
| 222 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 223 | + | return err | |||
| 224 | + | } | |||
| 225 | + | a.configNotice = "Deleted search shortcut " + key | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error { | |||
| 230 | + | cfg, err := a.editableConfig(ctx) | |||
| 231 | + | if err != nil { | |||
| 232 | + | return err | |||
| 233 | + | } | |||
| 234 | + | profile := strings.TrimSpace(data["profile"]) | |||
| 235 | + | if !isAllowedPerformanceProfile(profile) { | |||
| 236 | + | return fmt.Errorf("unknown performance profile: %q", profile) | |||
| 237 | + | } | |||
| 238 | + | cfg.Performance.Profile = profile | |||
| 239 | + | if profile != customPerformanceProfile { | |||
| 240 | + | if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil { | |||
| 241 | + | return saveErr | |||
| 242 | + | } | |||
| 243 | + | a.configNotice = "Saved performance settings. Restart may be required." | |||
| 244 | + | return nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads") | |||
| 248 | + | if err != nil { | |||
| 249 | + | return err | |||
| 250 | + | } | |||
| 251 | + | skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads") | |||
| 252 | + | if err != nil { | |||
| 253 | + | return err | |||
| 254 | + | } | |||
| 255 | + | webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory") | |||
| 256 | + | if err != nil { | |||
| 257 | + | return err | |||
| 258 | + | } | |||
| 259 | + | networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory") | |||
| 260 | + | if err != nil { | |||
| 261 | + | return err | |||
| 262 | + | } | |||
| 263 | + | prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm") | |||
| 264 | + | if err != nil { | |||
| 265 | + | return err | |||
| 266 | + | } | |||
| 267 | + | if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil { | |||
| 268 | + | return err | |||
| 269 | + | } | |||
| 270 | + | if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil { | |||
| 271 | + | return err | |||
| 272 | + | } | |||
| 273 | + | if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil { | |||
| 274 | + | return err | |||
| 275 | + | } | |||
| 276 | + | if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil { | |||
| 277 | + | return err | |||
| 278 | + | } | |||
| 279 | + | if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil { | |||
| 280 | + | return err | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{ | |||
| 284 | + | SkiaCPUThreads: skiaCPU, | |||
| 285 | + | SkiaGPUThreads: skiaGPU, | |||
| 286 | + | WebProcessMemoryMB: webMemory, | |||
| 287 | + | NetworkProcessMemoryMB: networkMemory, | |||
| 288 | + | WebViewPoolPrewarm: prewarm, | |||
| 289 | + | } | |||
| 290 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 291 | + | return err | |||
| 292 | + | } | |||
| 293 | + | a.configNotice = "Saved performance settings. Restart may be required." | |||
| 294 | + | return nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error { | |||
| 298 | + | mode := strings.TrimSpace(data["mode"]) | |||
| 299 | + | action := strings.TrimSpace(data["action"]) | |||
| 300 | + | keys := parseKeyList(data["keys"]) | |||
| 301 | + | if mode == "" || action == "" { | |||
| 302 | + | return fmt.Errorf("mode and action are required") | |||
| 303 | + | } | |||
| 304 | + | if err := a.requireKeybindingTarget(ctx, mode, action); err != nil { | |||
| 305 | + | return err | |||
| 306 | + | } | |||
| 307 | + | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | |||
| 308 | + | RequestID: nextConfigRequestID(), | |||
| 309 | + | Mode: mode, | |||
| 310 | + | Action: action, | |||
| 311 | + | Keys: keys, | |||
| 312 | + | }) | |||
| 313 | + | if err != nil { | |||
| 314 | + | return err | |||
| 315 | + | } | |||
| 316 | + | if conflicts := len(resp.Conflicts); conflicts > 0 { | |||
| 317 | + | a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts) | |||
| 318 | + | return nil | |||
| 319 | + | } | |||
| 320 | + | a.configNotice = "Saved keybinding " + action | |||
| 321 | + | return nil | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error { | |||
| 325 | + | mode := strings.TrimSpace(data["mode"]) | |||
| 326 | + | action := strings.TrimSpace(data["action"]) | |||
| 327 | + | if mode == "" || action == "" { | |||
| 328 | + | return fmt.Errorf("mode and action are required") | |||
| 329 | + | } | |||
| 330 | + | if err := a.requireKeybindingTarget(ctx, mode, action); err != nil { | |||
| 331 | + | return err | |||
| 332 | + | } | |||
| 333 | + | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | |||
| 334 | + | RequestID: nextConfigRequestID(), | |||
| 335 | + | Mode: mode, | |||
| 336 | + | Action: action, | |||
| 337 | + | }); err != nil { | |||
| 338 | + | return err | |||
| 339 | + | } | |||
| 340 | + | a.configNotice = "Reset keybinding " + action | |||
| 341 | + | return nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) { | |||
| 345 | + | cfg, err := a.deps.Config.Current(ctx) | |||
| 346 | + | if err != nil { | |||
| 347 | + | return dto.WebUIConfig{}, err | |||
| 348 | + | } | |||
| 349 | + | a.config = &cfg | |||
| 350 | + | return webUIConfigFromPayload(cfg), nil | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error { | |||
| 354 | + | keybindings, err := a.deps.Config.GetKeybindings(ctx) | |||
| 355 | + | if err != nil { | |||
| 356 | + | return err | |||
| 357 | + | } | |||
| 358 | + | a.keybindings = keybindings | |||
| 359 | + | groups := configKeybindingGroups(keybindings) | |||
| 360 | + | for _, group := range groups { | |||
| 361 | + | if group.Mode != mode { | |||
| 362 | + | continue | |||
| 363 | + | } | |||
| 364 | + | for _, binding := range group.Bindings { | |||
| 365 | + | if binding.Action == action { | |||
| 366 | + | return nil | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | return fmt.Errorf("keybinding %s/%s not found", mode, action) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error { | |||
| 374 | + | if err := a.deps.Config.Save(ctx, cfg); err != nil { | |||
| 375 | + | return err | |||
| 376 | + | } | |||
| 377 | + | a.config = nil | |||
| 378 | + | return nil | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig { | |||
| 382 | + | return dto.WebUIConfig{ | |||
| 383 | + | Appearance: payload.Appearance, | |||
| 384 | + | DefaultUIScale: payload.DefaultUIScale, | |||
| 385 | + | DefaultSearchEngine: payload.DefaultSearchEngine, | |||
| 386 | + | SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts), | |||
| 387 | + | Performance: dto.WebUIPerformanceConfig{ | |||
| 388 | + | Profile: payload.Performance.Profile, | |||
| 389 | + | Custom: dto.WebUICustomPerformanceConfig{ | |||
| 390 | + | SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads, | |||
| 391 | + | SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads, | |||
| 392 | + | WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB, | |||
| 393 | + | NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB, | |||
| 394 | + | WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm, | |||
| 395 | + | }, | |||
| 396 | + | }, | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut { | |||
| 401 | + | clone := make(map[string]dto.SearchShortcut, len(shortcuts)) | |||
| 402 | + | for key, shortcut := range shortcuts { | |||
| 403 | + | clone[key] = shortcut | |||
| 404 | + | } | |||
| 405 | + | return clone | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette { | |||
| 409 | + | color := func(field string) string { | |||
| 410 | + | return strings.TrimSpace(data[prefix+"_"+field]) | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | return dto.ColorPalette{ | |||
| 414 | + | Background: color("background"), | |||
| 415 | + | Surface: color("surface"), | |||
| 416 | + | SurfaceVariant: color("surface_variant"), | |||
| 417 | + | Text: color("text"), | |||
| 418 | + | Muted: color("muted"), | |||
| 419 | + | Accent: color("accent"), | |||
| 420 | + | Border: color("border"), | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) { | |||
| 425 | + | return strings.TrimSpace(data[keyField]), dto.SearchShortcut{ | |||
| 426 | + | URL: strings.TrimSpace(data["url"]), | |||
| 427 | + | Description: strings.TrimSpace(data["description"]), | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | func requireSearchURLTemplate(raw, label string) (string, error) { | |||
| 432 | + | value := strings.TrimSpace(raw) | |||
| 433 | + | if value == "" { | |||
| 434 | + | return "", fmt.Errorf("%s is required", label) | |||
| 435 | + | } | |||
| 436 | + | if !strings.Contains(value, "%s") { | |||
| 437 | + | return "", fmt.Errorf("%s must include %%s placeholder", label) | |||
| 438 | + | } | |||
| 439 | + | parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1)) | |||
| 440 | + | if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") { | |||
| 441 | + | return "", fmt.Errorf("%s must be an absolute http(s) URL", label) | |||
| 442 | + | } | |||
| 443 | + | return value, nil | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func parseConfigInt(raw, label string) (int, error) { | |||
| 447 | + | value, err := strconv.Atoi(strings.TrimSpace(raw)) | |||
| 448 | + | if err != nil { | |||
| 449 | + | return 0, fmt.Errorf("invalid %s", label) | |||
| 450 | + | } | |||
| 451 | + | return value, nil | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func parseConfigFloat(raw, label string) (float64, error) { | |||
| 455 | + | value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) | |||
| 456 | + | if err != nil { | |||
| 457 | + | return 0, fmt.Errorf("invalid %s", label) | |||
| 458 | + | } | |||
| 459 | + | return value, nil | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func isAllowedPerformanceProfile(profile string) bool { | |||
| 463 | + | switch profile { | |||
| 464 | + | case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile: | |||
| 465 | + | return true | |||
| 466 | + | default: | |||
| 467 | + | return false | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func validatePerformanceInt(value int, label string, minValue, maxValue int) error { | |||
| 472 | + | if value < minValue || value > maxValue { | |||
| 473 | + | return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue) | |||
| 474 | + | } | |||
| 475 | + | return nil | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func parseKeyList(raw string) []string { | |||
| 479 | + | raw = strings.TrimSpace(raw) | |||
| 480 | + | if raw == "" { | |||
| 481 | + | return nil | |||
| 482 | + | } | |||
| 483 | + | raw = strings.ReplaceAll(raw, ",", " ") | |||
| 484 | + | parts := strings.Fields(raw) | |||
| 485 | + | keys := make([]string, 0, len(parts)) | |||
| 486 | + | for _, part := range parts { | |||
| 487 | + | key := strings.TrimSpace(part) | |||
| 488 | + | if key != "" { | |||
| 489 | + | keys = append(keys, key) | |||
| 490 | + | } | |||
| 491 | + | } | |||
| 492 | + | return keys | |||
| 493 | + | } | |||
A internal/ui/systemviews/config_templ.go +1332 -0 generated file: collapsed
A internal/ui/systemviews/config_view.go +373 -0
| @@ -0,0 +1,373 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "reflect" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type configRenderData struct { | |||
| 14 | + | Config dto.SystemviewConfigPayload | |||
| 15 | + | Keybindings port.KeybindingsConfig | |||
| 16 | + | Notice string | |||
| 17 | + | Error string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // maxFlattenDepth caps recursive config flattening to avoid runaway nesting. | |||
| 21 | + | const maxFlattenDepth = 16 | |||
| 22 | + | ||||
| 23 | + | func configHTML(data configRenderData) string { | |||
| 24 | + | return mustRenderComponent(ConfigView(data)) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func configKeybindingSummary(keybindings port.KeybindingsConfig) string { | |||
| 28 | + | if keybindings.Groups == nil { | |||
| 29 | + | return "Keybindings unavailable" | |||
| 30 | + | } | |||
| 31 | + | groups, bindings := summarizeKeybindings(keybindings) | |||
| 32 | + | return fmt.Sprintf( | |||
| 33 | + | "Keybindings loaded (%d %s, %d %s)", | |||
| 34 | + | groups, | |||
| 35 | + | plural(groups, "group"), | |||
| 36 | + | bindings, | |||
| 37 | + | plural(bindings, "binding"), | |||
| 38 | + | ) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup { | |||
| 42 | + | if keybindings.Groups == nil { | |||
| 43 | + | return nil | |||
| 44 | + | } | |||
| 45 | + | return convertPortKeybindingGroups(keybindings.Groups) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func keybindingGroupTitle(group renderedKeybindingGroup) string { | |||
| 49 | + | title := strings.TrimSpace(group.DisplayName) | |||
| 50 | + | if title == "" { | |||
| 51 | + | title = strings.TrimSpace(group.Mode) | |||
| 52 | + | } | |||
| 53 | + | if title == "" { | |||
| 54 | + | return "Keybinding group" | |||
| 55 | + | } | |||
| 56 | + | return title | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func keybindingGroupMeta(group renderedKeybindingGroup) string { | |||
| 60 | + | meta := make([]string, 0, 2) | |||
| 61 | + | if strings.TrimSpace(group.Mode) != "" { | |||
| 62 | + | meta = append(meta, "mode: "+group.Mode) | |||
| 63 | + | } | |||
| 64 | + | if strings.TrimSpace(group.Activation) != "" { | |||
| 65 | + | meta = append(meta, "activation: "+group.Activation) | |||
| 66 | + | } | |||
| 67 | + | return strings.Join(meta, " • ") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func keybindingDescription(binding renderedKeybinding) string { | |||
| 71 | + | description := strings.TrimSpace(binding.Description) | |||
| 72 | + | if description != "" { | |||
| 73 | + | return description | |||
| 74 | + | } | |||
| 75 | + | if strings.TrimSpace(binding.Action) != "" { | |||
| 76 | + | return strings.TrimSpace(binding.Action) | |||
| 77 | + | } | |||
| 78 | + | return "Unnamed binding" | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func keybindingAction(binding renderedKeybinding) string { | |||
| 82 | + | action := strings.TrimSpace(binding.Action) | |||
| 83 | + | if action == "" { | |||
| 84 | + | return "binding" | |||
| 85 | + | } | |||
| 86 | + | return action | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func keybindingIsCustom(binding renderedKeybinding) bool { | |||
| 90 | + | return binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func keybindingStatus(binding renderedKeybinding) string { | |||
| 94 | + | if keybindingIsCustom(binding) { | |||
| 95 | + | return "custom" | |||
| 96 | + | } | |||
| 97 | + | return "default" | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func keybindingKeysValue(keys []string) string { | |||
| 101 | + | return strings.Join(keys, ", ") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | type searchShortcutRow struct { | |||
| 105 | + | Key string | |||
| 106 | + | URL string | |||
| 107 | + | Description string | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func searchShortcutRows(shortcuts map[string]dto.SearchShortcut) []searchShortcutRow { | |||
| 111 | + | if len(shortcuts) == 0 { | |||
| 112 | + | return nil | |||
| 113 | + | } | |||
| 114 | + | keys := make([]string, 0, len(shortcuts)) | |||
| 115 | + | for key := range shortcuts { | |||
| 116 | + | keys = append(keys, key) | |||
| 117 | + | } | |||
| 118 | + | sort.Strings(keys) | |||
| 119 | + | rows := make([]searchShortcutRow, 0, len(keys)) | |||
| 120 | + | for _, key := range keys { | |||
| 121 | + | shortcut := shortcuts[key] | |||
| 122 | + | rows = append(rows, searchShortcutRow{ | |||
| 123 | + | Key: key, | |||
| 124 | + | URL: shortcut.URL, | |||
| 125 | + | Description: shortcut.Description, | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | return rows | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func formatConfigInt(value int) string { | |||
| 132 | + | return fmt.Sprintf("%d", value) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func formatConfigFloat(value float64) string { | |||
| 136 | + | return fmt.Sprintf("%g", value) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func colorSchemeSelected(current, option string) bool { | |||
| 140 | + | return strings.EqualFold(strings.TrimSpace(current), option) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func performanceProfileSelected(current, option string) bool { | |||
| 144 | + | return strings.EqualFold(strings.TrimSpace(current), option) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func engineIsWebKit(engine string) bool { | |||
| 148 | + | return strings.EqualFold(strings.TrimSpace(engine), "webkit") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func flattenValues(value any) []kvPair { | |||
| 152 | + | rows := make([]kvPair, 0) | |||
| 153 | + | flattenValue(&rows, "", reflect.ValueOf(value), 0) | |||
| 154 | + | return rows | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||
| 158 | + | if !value.IsValid() { | |||
| 159 | + | appendFlatValue(rows, path, "null") | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | if depth >= maxFlattenDepth { | |||
| 163 | + | appendFlatValue(rows, path, "...") | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | for value.Kind() == reflect.Interface { | |||
| 168 | + | if value.IsNil() { | |||
| 169 | + | appendFlatValue(rows, path, "null") | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | value = value.Elem() | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | for value.Kind() == reflect.Pointer { | |||
| 176 | + | if value.IsNil() { | |||
| 177 | + | appendFlatValue(rows, path, "null") | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 180 | + | value = value.Elem() | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | switch value.Kind() { | |||
| 184 | + | case reflect.Struct: | |||
| 185 | + | flattenStructValue(rows, path, value, depth) | |||
| 186 | + | case reflect.Map: | |||
| 187 | + | flattenMapValue(rows, path, value, depth) | |||
| 188 | + | case reflect.Slice, reflect.Array: | |||
| 189 | + | flattenListValue(rows, path, value, depth) | |||
| 190 | + | default: | |||
| 191 | + | appendFlatValue(rows, path, formatFlatValue(value)) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||
| 196 | + | typeOfValue := value.Type() | |||
| 197 | + | for i := 0; i < value.NumField(); i++ { | |||
| 198 | + | field := typeOfValue.Field(i) | |||
| 199 | + | if !field.IsExported() { | |||
| 200 | + | continue | |||
| 201 | + | } | |||
| 202 | + | name := jsonFieldName(field) | |||
| 203 | + | if name == "-" { | |||
| 204 | + | continue | |||
| 205 | + | } | |||
| 206 | + | flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||
| 211 | + | if value.Len() == 0 { | |||
| 212 | + | appendFlatValue(rows, path, "{}") | |||
| 213 | + | return | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | keys := value.MapKeys() | |||
| 217 | + | sort.Slice(keys, func(i, j int) bool { | |||
| 218 | + | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |||
| 219 | + | }) | |||
| 220 | + | ||||
| 221 | + | for _, key := range keys { | |||
| 222 | + | name := mapKeyString(key) | |||
| 223 | + | flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) { | |||
| 228 | + | if value.Len() == 0 { | |||
| 229 | + | appendFlatValue(rows, path, "[]") | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | for i := 0; i < value.Len(); i++ { | |||
| 234 | + | nextPath := fmt.Sprintf("%s[%d]", path, i) | |||
| 235 | + | if path == "" { | |||
| 236 | + | nextPath = fmt.Sprintf("[%d]", i) | |||
| 237 | + | } | |||
| 238 | + | flattenValue(rows, nextPath, value.Index(i), depth+1) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func childFlatPath(path, name string) string { | |||
| 243 | + | if path == "" { | |||
| 244 | + | return name | |||
| 245 | + | } | |||
| 246 | + | return path + "." + name | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func appendFlatValue(rows *[]kvPair, path, value string) { | |||
| 250 | + | if strings.TrimSpace(path) == "" { | |||
| 251 | + | path = "value" | |||
| 252 | + | } | |||
| 253 | + | *rows = append(*rows, kvPair{Label: path, Value: value}) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func formatFlatValue(value reflect.Value) string { | |||
| 257 | + | if !value.IsValid() { | |||
| 258 | + | return "null" | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | switch value.Kind() { | |||
| 262 | + | case reflect.String: | |||
| 263 | + | return value.String() | |||
| 264 | + | case reflect.Bool: | |||
| 265 | + | return fmt.Sprint(value.Bool()) | |||
| 266 | + | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |||
| 267 | + | return fmt.Sprint(value.Int()) | |||
| 268 | + | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: | |||
| 269 | + | return fmt.Sprint(value.Uint()) | |||
| 270 | + | case reflect.Float32, reflect.Float64: | |||
| 271 | + | return fmt.Sprint(value.Float()) | |||
| 272 | + | case reflect.Complex64, reflect.Complex128: | |||
| 273 | + | return fmt.Sprint(value.Complex()) | |||
| 274 | + | default: | |||
| 275 | + | if !value.CanInterface() { | |||
| 276 | + | return "<unexported>" | |||
| 277 | + | } | |||
| 278 | + | return fmt.Sprint(value.Interface()) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func jsonFieldName(field reflect.StructField) string { | |||
| 283 | + | name := field.Tag.Get("json") | |||
| 284 | + | if name == "" { | |||
| 285 | + | return field.Name | |||
| 286 | + | } | |||
| 287 | + | if idx := strings.Index(name, ","); idx >= 0 { | |||
| 288 | + | name = name[:idx] | |||
| 289 | + | } | |||
| 290 | + | if name == "" { | |||
| 291 | + | return field.Name | |||
| 292 | + | } | |||
| 293 | + | return name | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func mapKeyString(value reflect.Value) string { | |||
| 297 | + | if value.Kind() == reflect.String { | |||
| 298 | + | return value.String() | |||
| 299 | + | } | |||
| 300 | + | if value.CanInterface() { | |||
| 301 | + | return fmt.Sprint(value.Interface()) | |||
| 302 | + | } | |||
| 303 | + | return formatFlatValue(value) | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) { | |||
| 307 | + | for _, group := range keybindings.Groups { | |||
| 308 | + | groups++ | |||
| 309 | + | bindings += len(group.Bindings) | |||
| 310 | + | } | |||
| 311 | + | return groups, bindings | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup { | |||
| 315 | + | rendered := make([]renderedKeybindingGroup, 0, len(groups)) | |||
| 316 | + | for _, group := range groups { | |||
| 317 | + | rendered = append(rendered, renderedKeybindingGroup{ | |||
| 318 | + | DisplayName: strings.TrimSpace(group.DisplayName), | |||
| 319 | + | Mode: strings.TrimSpace(group.Mode), | |||
| 320 | + | Activation: strings.TrimSpace(group.Activation), | |||
| 321 | + | Bindings: convertPortKeybindingEntries(group.Bindings), | |||
| 322 | + | }) | |||
| 323 | + | } | |||
| 324 | + | return rendered | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding { | |||
| 328 | + | rendered := make([]renderedKeybinding, 0, len(bindings)) | |||
| 329 | + | for _, binding := range bindings { | |||
| 330 | + | rendered = append(rendered, renderedKeybinding{ | |||
| 331 | + | Description: strings.TrimSpace(binding.Description), | |||
| 332 | + | Action: strings.TrimSpace(binding.Action), | |||
| 333 | + | Keys: append([]string(nil), binding.Keys...), | |||
| 334 | + | DefaultKeys: append([]string(nil), binding.DefaultKeys...), | |||
| 335 | + | IsCustom: binding.IsCustom, | |||
| 336 | + | }) | |||
| 337 | + | } | |||
| 338 | + | return rendered | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | type renderedKeybindingGroup struct { | |||
| 342 | + | DisplayName string | |||
| 343 | + | Mode string | |||
| 344 | + | Activation string | |||
| 345 | + | Bindings []renderedKeybinding | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | type renderedKeybinding struct { | |||
| 349 | + | Description string | |||
| 350 | + | Action string | |||
| 351 | + | Keys []string | |||
| 352 | + | DefaultKeys []string | |||
| 353 | + | IsCustom bool | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func sameStringSlice(a, b []string) bool { | |||
| 357 | + | if len(a) != len(b) { | |||
| 358 | + | return false | |||
| 359 | + | } | |||
| 360 | + | for i := range a { | |||
| 361 | + | if a[i] != b[i] { | |||
| 362 | + | return false | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | return true | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func plural(count int, singular string) string { | |||
| 369 | + | if count == 1 { | |||
| 370 | + | return singular | |||
| 371 | + | } | |||
| 372 | + | return singular + "s" | |||
| 373 | + | } | |||
A internal/ui/systemviews/dom.go +40 -0
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "html" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | type DOM interface { | |||
| 9 | + | Mount(markup string) error | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // DOMAction is a browser event delegated from the mounted systemview DOM. | |||
| 13 | + | type DOMAction struct { | |||
| 14 | + | Action string | |||
| 15 | + | Data map[string]string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // DOMActionHandler handles delegated browser actions. | |||
| 19 | + | type DOMActionHandler func(DOMAction) | |||
| 20 | + | ||||
| 21 | + | // DOMActionBinder is implemented by DOM adapters that can delegate events back | |||
| 22 | + | // to the App. Tests can keep using the smaller DOM interface. | |||
| 23 | + | type DOMActionBinder interface { | |||
| 24 | + | BindActions(handler DOMActionHandler) error | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // DOMHistoryTimelineAppender is implemented by DOM adapters that can append | |||
| 28 | + | // older history windows without reparsing and remounting the whole page. | |||
| 29 | + | type DOMHistoryTimelineAppender interface { | |||
| 30 | + | AppendHistoryTimeline(markup string) error | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func placeholderHTML(route Route) string { | |||
| 34 | + | name := html.EscapeString(string(route)) | |||
| 35 | + | if name == "" || route == RouteUnknown { | |||
| 36 | + | name = "unknown" | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | return fmt.Sprintf(`<p>systemviews %s</p>`, name) | |||
| 40 | + | } | |||
A internal/ui/systemviews/dom_js.go +819 -0
| @@ -0,0 +1,819 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | "syscall/js" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type browserDOM struct { | |||
| 13 | + | document js.Value | |||
| 14 | + | target js.Value | |||
| 15 | + | bindings []jsEventBinding | |||
| 16 | + | historyObserver js.Value | |||
| 17 | + | historyObserverCallback js.Func | |||
| 18 | + | activeConfirmCleanup func() | |||
| 19 | + | activeConfirmID uint64 | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type jsEventBinding struct { | |||
| 23 | + | target js.Value | |||
| 24 | + | event string | |||
| 25 | + | fn js.Func | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func NewDOM() DOM { | |||
| 29 | + | doc := js.Global().Get("document") | |||
| 30 | + | if !doc.Truthy() { | |||
| 31 | + | return &browserDOM{} | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | target := doc.Call("getElementById", "app") | |||
| 35 | + | return &browserDOM{document: doc, target: target} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (d *browserDOM) Mount(markup string) error { | |||
| 39 | + | if d == nil || !d.target.Truthy() { | |||
| 40 | + | return fmt.Errorf("DOM mount target #app not found") | |||
| 41 | + | } | |||
| 42 | + | d.target.Set("innerHTML", markup) | |||
| 43 | + | d.updateDocumentTitle() | |||
| 44 | + | d.scheduleAlertDismissal() | |||
| 45 | + | d.syncPerformanceCustomInputStates() | |||
| 46 | + | d.focusAutofocusTarget() | |||
| 47 | + | d.setupHistoryInfiniteScroll() | |||
| 48 | + | return nil | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (d *browserDOM) AppendHistoryTimeline(markup string) error { | |||
| 52 | + | if d == nil || !d.target.Truthy() { | |||
| 53 | + | return fmt.Errorf("DOM mount target #app not found") | |||
| 54 | + | } | |||
| 55 | + | timeline := d.target.Call("querySelector", "[data-sv-history-timeline]") | |||
| 56 | + | if !timeline.Truthy() { | |||
| 57 | + | return fmt.Errorf("history timeline target not found") | |||
| 58 | + | } | |||
| 59 | + | if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() { | |||
| 60 | + | old.Call("remove") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | container := d.document.Call("createElement", "div") | |||
| 64 | + | container.Set("innerHTML", markup) | |||
| 65 | + | d.mergeLeadingHistoryGroup(timeline, container) | |||
| 66 | + | for container.Get("firstChild").Truthy() { | |||
| 67 | + | timeline.Call("appendChild", container.Get("firstChild")) | |||
| 68 | + | } | |||
| 69 | + | d.setupHistoryInfiniteScroll() | |||
| 70 | + | return nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) { | |||
| 74 | + | if !timeline.Truthy() || !container.Truthy() { | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | first := container.Get("firstElementChild") | |||
| 78 | + | if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() { | |||
| 79 | + | return | |||
| 80 | + | } | |||
| 81 | + | date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String()) | |||
| 82 | + | if date == "" { | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | groups := timeline.Call("querySelectorAll", "[data-sv-history-date]") | |||
| 86 | + | if !groups.Truthy() || groups.Get("length").Int() == 0 { | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | lastGroup := groups.Index(groups.Get("length").Int() - 1) | |||
| 90 | + | if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date { | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | list := lastGroup.Call("querySelector", "ul") | |||
| 94 | + | if !list.Truthy() { | |||
| 95 | + | return | |||
| 96 | + | } | |||
| 97 | + | for first.Get("firstChild").Truthy() { | |||
| 98 | + | list.Call("appendChild", first.Get("firstChild")) | |||
| 99 | + | } | |||
| 100 | + | if first.Get("remove").Truthy() { | |||
| 101 | + | first.Call("remove") | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (d *browserDOM) updateDocumentTitle() { | |||
| 106 | + | if d == nil || !d.document.Truthy() || !d.target.Truthy() { | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | root := d.target.Call("querySelector", "[data-page-title]") | |||
| 110 | + | if !root.Truthy() { | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String()) | |||
| 114 | + | if title == "" { | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | d.document.Set("title", title) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (d *browserDOM) focusAutofocusTarget() { | |||
| 121 | + | if d == nil || !d.target.Truthy() { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | target := d.target.Call("querySelector", "[data-sv-autofocus]") | |||
| 125 | + | if !target.Truthy() || !target.Get("focus").Truthy() { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | target.Call("focus") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (d *browserDOM) scheduleAlertDismissal() { | |||
| 132 | + | if d == nil || !d.target.Truthy() { | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | alerts := d.target.Call("querySelectorAll", ".sv-alert") | |||
| 136 | + | length := alerts.Get("length").Int() | |||
| 137 | + | if length == 0 { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | setTimeout := js.Global().Get("setTimeout") | |||
| 141 | + | if !setTimeout.Truthy() { | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | for i := 0; i < length; i++ { | |||
| 145 | + | alert := alerts.Index(i) | |||
| 146 | + | if !alert.Truthy() { | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | scheduleAlertRemoval(setTimeout, alert) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func scheduleAlertRemoval(setTimeout, alert js.Value) { | |||
| 154 | + | var fadeFn js.Func | |||
| 155 | + | fadeFn = js.FuncOf(func(js.Value, []js.Value) any { | |||
| 156 | + | defer fadeFn.Release() | |||
| 157 | + | if !alert.Truthy() || !alert.Get("classList").Truthy() { | |||
| 158 | + | return nil | |||
| 159 | + | } | |||
| 160 | + | alert.Get("classList").Call("add", "sv-alert-dismiss") | |||
| 161 | + | ||||
| 162 | + | var removeFn js.Func | |||
| 163 | + | removeFn = js.FuncOf(func(js.Value, []js.Value) any { | |||
| 164 | + | defer removeFn.Release() | |||
| 165 | + | if alert.Truthy() && alert.Get("remove").Truthy() { | |||
| 166 | + | alert.Call("remove") | |||
| 167 | + | } | |||
| 168 | + | return nil | |||
| 169 | + | }) | |||
| 170 | + | setTimeout.Invoke(removeFn, 220) | |||
| 171 | + | return nil | |||
| 172 | + | }) | |||
| 173 | + | setTimeout.Invoke(fadeFn, 4200) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (d *browserDOM) BindActions(handler DOMActionHandler) error { | |||
| 177 | + | if d == nil || !d.target.Truthy() || handler == nil { | |||
| 178 | + | return fmt.Errorf("DOM action binding target not available") | |||
| 179 | + | } | |||
| 180 | + | d.Release() | |||
| 181 | + | ||||
| 182 | + | clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 183 | + | event := firstJSArg(args) | |||
| 184 | + | if !event.Truthy() { | |||
| 185 | + | return nil | |||
| 186 | + | } | |||
| 187 | + | element := closestActionElement(event) | |||
| 188 | + | if !element.Truthy() { | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | if strings.EqualFold(element.Get("tagName").String(), "form") { | |||
| 192 | + | target := event.Get("target") | |||
| 193 | + | if !target.Truthy() { | |||
| 194 | + | target = event.Get("srcElement") | |||
| 195 | + | } | |||
| 196 | + | if !target.Truthy() || !element.Equal(target) { | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | action := element.Call("getAttribute", "data-sv-action").String() | |||
| 201 | + | if action == "" { | |||
| 202 | + | return nil | |||
| 203 | + | } | |||
| 204 | + | event.Call("preventDefault") | |||
| 205 | + | data := collectActionData(element) | |||
| 206 | + | d.confirmAction(element, func() { | |||
| 207 | + | handler(DOMAction{Action: action, Data: data}) | |||
| 208 | + | }) | |||
| 209 | + | return nil | |||
| 210 | + | }) | |||
| 211 | + | d.addEventBinding(d.target, "click", clickHandler) | |||
| 212 | + | ||||
| 213 | + | submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 214 | + | event := firstJSArg(args) | |||
| 215 | + | if !event.Truthy() { | |||
| 216 | + | return nil | |||
| 217 | + | } | |||
| 218 | + | form := event.Get("target") | |||
| 219 | + | if !form.Truthy() { | |||
| 220 | + | return nil | |||
| 221 | + | } | |||
| 222 | + | action := form.Call("getAttribute", "data-sv-action").String() | |||
| 223 | + | if action == "" { | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | event.Call("preventDefault") | |||
| 227 | + | updatePerformanceCustomInputs(form) | |||
| 228 | + | data := collectActionData(form) | |||
| 229 | + | for key, value := range collectFormData(form) { | |||
| 230 | + | data[key] = value | |||
| 231 | + | } | |||
| 232 | + | d.confirmAction(form, func() { | |||
| 233 | + | handler(DOMAction{Action: action, Data: data}) | |||
| 234 | + | }) | |||
| 235 | + | return nil | |||
| 236 | + | }) | |||
| 237 | + | d.addEventBinding(d.target, "submit", submitHandler) | |||
| 238 | + | ||||
| 239 | + | changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 240 | + | event := firstJSArg(args) | |||
| 241 | + | if !event.Truthy() { | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | target := event.Get("target") | |||
| 245 | + | if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() { | |||
| 246 | + | return nil | |||
| 247 | + | } | |||
| 248 | + | form := target.Get("form") | |||
| 249 | + | if form.Truthy() { | |||
| 250 | + | updatePerformanceCustomInputs(form) | |||
| 251 | + | } | |||
| 252 | + | return nil | |||
| 253 | + | }) | |||
| 254 | + | d.addEventBinding(d.target, "change", changeHandler) | |||
| 255 | + | ||||
| 256 | + | keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 257 | + | event := firstJSArg(args) | |||
| 258 | + | if !d.eventTargetInsideMount(event) { | |||
| 259 | + | return nil | |||
| 260 | + | } | |||
| 261 | + | d.handleKeyboard(event) | |||
| 262 | + | return nil | |||
| 263 | + | }) | |||
| 264 | + | d.addEventBinding(d.document, "keydown", keydownHandler) | |||
| 265 | + | ||||
| 266 | + | return nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (d *browserDOM) eventTargetInsideMount(event js.Value) bool { | |||
| 270 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |||
| 271 | + | return false | |||
| 272 | + | } | |||
| 273 | + | target := event.Get("target") | |||
| 274 | + | if !target.Truthy() { | |||
| 275 | + | target = event.Get("srcElement") | |||
| 276 | + | } | |||
| 277 | + | if !target.Truthy() { | |||
| 278 | + | return false | |||
| 279 | + | } | |||
| 280 | + | if d.document.Truthy() && target.Equal(d.document) { | |||
| 281 | + | return true | |||
| 282 | + | } | |||
| 283 | + | if strings.EqualFold(target.Get("nodeName").String(), "body") { | |||
| 284 | + | return true | |||
| 285 | + | } | |||
| 286 | + | return d.target.Call("contains", target).Bool() | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) { | |||
| 290 | + | if !target.Truthy() { | |||
| 291 | + | fn.Release() | |||
| 292 | + | return | |||
| 293 | + | } | |||
| 294 | + | target.Call("addEventListener", event, fn) | |||
| 295 | + | d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn}) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (d *browserDOM) setupHistoryInfiniteScroll() { | |||
| 299 | + | if d == nil || !d.target.Truthy() { | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | // setupHistoryInfiniteScroll callbacks close over the current load-more | |||
| 303 | + | // button. disconnectHistoryObserver owns the cleanup chain: it calls | |||
| 304 | + | // d.historyObserver.disconnect(), releases d.historyObserverCallback, and | |||
| 305 | + | // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new | |||
| 306 | + | // observer so old button closures are not retained. | |||
| 307 | + | d.disconnectHistoryObserver() | |||
| 308 | + | observerCtor := js.Global().Get("IntersectionObserver") | |||
| 309 | + | if !observerCtor.Truthy() { | |||
| 310 | + | return | |||
| 311 | + | } | |||
| 312 | + | button := d.target.Call("querySelector", "[data-sv-history-load-more]") | |||
| 313 | + | if !button.Truthy() { | |||
| 314 | + | return | |||
| 315 | + | } | |||
| 316 | + | callback := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 317 | + | if len(args) == 0 || !button.Truthy() { | |||
| 318 | + | return nil | |||
| 319 | + | } | |||
| 320 | + | entries := args[0] | |||
| 321 | + | for i := 0; i < entries.Get("length").Int(); i++ { | |||
| 322 | + | entry := entries.Index(i) | |||
| 323 | + | if !entry.Truthy() || !entry.Get("isIntersecting").Bool() { | |||
| 324 | + | continue | |||
| 325 | + | } | |||
| 326 | + | if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" { | |||
| 327 | + | return nil | |||
| 328 | + | } | |||
| 329 | + | // A disabled button does not dispatch click events. Dispatch first, then | |||
| 330 | + | // mark the control busy so the observer cannot enqueue duplicate loads | |||
| 331 | + | // while the WASM action worker fetches the next history window. | |||
| 332 | + | button.Call("click") | |||
| 333 | + | button.Get("dataset").Set("svLoading", "true") | |||
| 334 | + | button.Set("disabled", true) | |||
| 335 | + | button.Set("textContent", "Loading older visits…") | |||
| 336 | + | return nil | |||
| 337 | + | } | |||
| 338 | + | return nil | |||
| 339 | + | }) | |||
| 340 | + | d.historyObserverCallback = callback | |||
| 341 | + | options := js.Global().Get("Object").New() | |||
| 342 | + | options.Set("rootMargin", "400px 0px") | |||
| 343 | + | d.historyObserver = observerCtor.New(callback, options) | |||
| 344 | + | d.historyObserver.Call("observe", button) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func (d *browserDOM) disconnectHistoryObserver() { | |||
| 348 | + | if d == nil { | |||
| 349 | + | return | |||
| 350 | + | } | |||
| 351 | + | if d.historyObserver.Truthy() { | |||
| 352 | + | d.historyObserver.Call("disconnect") | |||
| 353 | + | d.historyObserver = js.Value{} | |||
| 354 | + | } | |||
| 355 | + | if d.historyObserverCallback.Truthy() { | |||
| 356 | + | d.historyObserverCallback.Release() | |||
| 357 | + | d.historyObserverCallback = js.Func{} | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func (d *browserDOM) Release() { | |||
| 362 | + | if d == nil { | |||
| 363 | + | return | |||
| 364 | + | } | |||
| 365 | + | d.disconnectHistoryObserver() | |||
| 366 | + | d.cleanupActiveConfirmDialog() | |||
| 367 | + | for _, binding := range d.bindings { | |||
| 368 | + | if binding.target.Truthy() { | |||
| 369 | + | binding.target.Call("removeEventListener", binding.event, binding.fn) | |||
| 370 | + | } | |||
| 371 | + | binding.fn.Release() | |||
| 372 | + | } | |||
| 373 | + | d.bindings = nil | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func firstJSArg(args []js.Value) js.Value { | |||
| 377 | + | if len(args) == 0 { | |||
| 378 | + | return js.Value{} | |||
| 379 | + | } | |||
| 380 | + | return args[0] | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | func closestActionElement(event js.Value) js.Value { | |||
| 384 | + | target := event.Get("target") | |||
| 385 | + | if !target.Truthy() || !target.Get("closest").Truthy() { | |||
| 386 | + | return js.Value{} | |||
| 387 | + | } | |||
| 388 | + | return target.Call("closest", "[data-sv-action]") | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func collectActionData(element js.Value) map[string]string { | |||
| 392 | + | data := map[string]string{} | |||
| 393 | + | if !element.Truthy() { | |||
| 394 | + | return data | |||
| 395 | + | } | |||
| 396 | + | dataset := element.Get("dataset") | |||
| 397 | + | if !dataset.Truthy() { | |||
| 398 | + | return data | |||
| 399 | + | } | |||
| 400 | + | keys := js.Global().Get("Object").Call("keys", dataset) | |||
| 401 | + | for i := 0; i < keys.Get("length").Int(); i++ { | |||
| 402 | + | key := keys.Index(i).String() | |||
| 403 | + | if key == "svAction" || key == "svConfirm" { | |||
| 404 | + | continue | |||
| 405 | + | } | |||
| 406 | + | data[key] = dataset.Get(key).String() | |||
| 407 | + | } | |||
| 408 | + | return data | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func (d *browserDOM) syncPerformanceCustomInputStates() { | |||
| 412 | + | if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() { | |||
| 413 | + | return | |||
| 414 | + | } | |||
| 415 | + | forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]") | |||
| 416 | + | for i := 0; i < forms.Get("length").Int(); i++ { | |||
| 417 | + | updatePerformanceCustomInputs(forms.Index(i)) | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | func updatePerformanceCustomInputs(form js.Value) { | |||
| 422 | + | if !form.Truthy() || !form.Get("querySelector").Truthy() { | |||
| 423 | + | return | |||
| 424 | + | } | |||
| 425 | + | selectEl := form.Call("querySelector", "[data-sv-performance-profile]") | |||
| 426 | + | if !selectEl.Truthy() { | |||
| 427 | + | return | |||
| 428 | + | } | |||
| 429 | + | enabled := strings.EqualFold(selectEl.Get("value").String(), "custom") | |||
| 430 | + | inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]") | |||
| 431 | + | for i := 0; i < inputs.Get("length").Int(); i++ { | |||
| 432 | + | inputs.Index(i).Set("disabled", !enabled) | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func collectFormData(form js.Value) map[string]string { | |||
| 437 | + | data := map[string]string{} | |||
| 438 | + | if !form.Truthy() || !js.Global().Get("FormData").Truthy() { | |||
| 439 | + | return data | |||
| 440 | + | } | |||
| 441 | + | formData := js.Global().Get("FormData").New(form) | |||
| 442 | + | entries := js.Global().Get("Array").Call("from", formData.Call("entries")) | |||
| 443 | + | for i := 0; i < entries.Get("length").Int(); i++ { | |||
| 444 | + | pair := entries.Index(i) | |||
| 445 | + | if pair.Get("length").Int() < 2 { | |||
| 446 | + | continue | |||
| 447 | + | } | |||
| 448 | + | key := pair.Index(0).String() | |||
| 449 | + | value := pair.Index(1).String() | |||
| 450 | + | if existing := data[key]; existing != "" { | |||
| 451 | + | data[key] = existing + "," + value | |||
| 452 | + | continue | |||
| 453 | + | } | |||
| 454 | + | data[key] = value | |||
| 455 | + | } | |||
| 456 | + | return data | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) { | |||
| 460 | + | if onConfirm == nil { | |||
| 461 | + | return | |||
| 462 | + | } | |||
| 463 | + | confirmAttr := element.Call("getAttribute", "data-sv-confirm") | |||
| 464 | + | if confirmAttr.Type() != js.TypeString { | |||
| 465 | + | onConfirm() | |||
| 466 | + | return | |||
| 467 | + | } | |||
| 468 | + | message := strings.TrimSpace(confirmAttr.String()) | |||
| 469 | + | if message == "" { | |||
| 470 | + | onConfirm() | |||
| 471 | + | return | |||
| 472 | + | } | |||
| 473 | + | if !d.showConfirmDialog(message, onConfirm) { | |||
| 474 | + | onConfirm() | |||
| 475 | + | } | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func (d *browserDOM) cleanupActiveConfirmDialog() { | |||
| 479 | + | if d == nil || d.activeConfirmCleanup == nil { | |||
| 480 | + | return | |||
| 481 | + | } | |||
| 482 | + | cleanup := d.activeConfirmCleanup | |||
| 483 | + | d.activeConfirmCleanup = nil | |||
| 484 | + | cleanup() | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool { | |||
| 488 | + | if d == nil || !d.document.Truthy() { | |||
| 489 | + | return false | |||
| 490 | + | } | |||
| 491 | + | body := d.document.Get("body") | |||
| 492 | + | if !body.Truthy() { | |||
| 493 | + | return false | |||
| 494 | + | } | |||
| 495 | + | // Always close an existing modal through its cleanup path so key handlers and | |||
| 496 | + | // js.Func values are released before the replacement is mounted. | |||
| 497 | + | d.cleanupActiveConfirmDialog() | |||
| 498 | + | ||||
| 499 | + | overlay := d.document.Call("createElement", "div") | |||
| 500 | + | overlay.Get("classList").Call("add", "sv-confirm-backdrop") | |||
| 501 | + | overlay.Set("role", "presentation") | |||
| 502 | + | ||||
| 503 | + | dialog := d.document.Call("createElement", "div") | |||
| 504 | + | dialog.Get("classList").Call("add", "sv-confirm-dialog") | |||
| 505 | + | dialog.Set("role", "dialog") | |||
| 506 | + | dialog.Call("setAttribute", "aria-modal", "true") | |||
| 507 | + | dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title") | |||
| 508 | + | dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message") | |||
| 509 | + | overlay.Call("appendChild", dialog) | |||
| 510 | + | ||||
| 511 | + | title := d.document.Call("createElement", "h3") | |||
| 512 | + | title.Set("id", "sv-confirm-title") | |||
| 513 | + | title.Set("textContent", "Confirm action") | |||
| 514 | + | dialog.Call("appendChild", title) | |||
| 515 | + | ||||
| 516 | + | text := d.document.Call("createElement", "p") | |||
| 517 | + | text.Set("id", "sv-confirm-message") | |||
| 518 | + | text.Set("textContent", message) | |||
| 519 | + | dialog.Call("appendChild", text) | |||
| 520 | + | ||||
| 521 | + | actions := d.document.Call("createElement", "div") | |||
| 522 | + | actions.Get("classList").Call("add", "sv-confirm-actions") | |||
| 523 | + | dialog.Call("appendChild", actions) | |||
| 524 | + | ||||
| 525 | + | cancelButton := d.document.Call("createElement", "button") | |||
| 526 | + | cancelButton.Set("type", "button") | |||
| 527 | + | cancelButton.Set("className", "sv-button sv-button-secondary") | |||
| 528 | + | cancelButton.Set("textContent", "Cancel") | |||
| 529 | + | actions.Call("appendChild", cancelButton) | |||
| 530 | + | ||||
| 531 | + | confirmButton := d.document.Call("createElement", "button") | |||
| 532 | + | confirmButton.Set("type", "button") | |||
| 533 | + | confirmButton.Set("className", "sv-button sv-button-danger") | |||
| 534 | + | confirmButton.Set("textContent", "Confirm") | |||
| 535 | + | actions.Call("appendChild", confirmButton) | |||
| 536 | + | ||||
| 537 | + | d.activeConfirmID++ | |||
| 538 | + | confirmID := d.activeConfirmID | |||
| 539 | + | ||||
| 540 | + | var cleanup func() | |||
| 541 | + | var cancelFn js.Func | |||
| 542 | + | var confirmFn js.Func | |||
| 543 | + | var keyFn js.Func | |||
| 544 | + | var backdropFn js.Func | |||
| 545 | + | cleaned := false | |||
| 546 | + | confirmed := false | |||
| 547 | + | cleanup = func() { | |||
| 548 | + | if cleaned { | |||
| 549 | + | return | |||
| 550 | + | } | |||
| 551 | + | cleaned = true | |||
| 552 | + | if d.activeConfirmID == confirmID { | |||
| 553 | + | d.activeConfirmCleanup = nil | |||
| 554 | + | } | |||
| 555 | + | if overlay.Truthy() { | |||
| 556 | + | overlay.Call("remove") | |||
| 557 | + | } | |||
| 558 | + | if d.document.Truthy() && keyFn.Value.Truthy() { | |||
| 559 | + | d.document.Call("removeEventListener", "keydown", keyFn) | |||
| 560 | + | } | |||
| 561 | + | cancelFn.Release() | |||
| 562 | + | confirmFn.Release() | |||
| 563 | + | keyFn.Release() | |||
| 564 | + | backdropFn.Release() | |||
| 565 | + | } | |||
| 566 | + | runConfirm := func() { | |||
| 567 | + | if confirmed { | |||
| 568 | + | return | |||
| 569 | + | } | |||
| 570 | + | confirmed = true | |||
| 571 | + | cleanup() | |||
| 572 | + | onConfirm() | |||
| 573 | + | } | |||
| 574 | + | cancelFn = js.FuncOf(func(js.Value, []js.Value) any { | |||
| 575 | + | cleanup() | |||
| 576 | + | return nil | |||
| 577 | + | }) | |||
| 578 | + | confirmFn = js.FuncOf(func(js.Value, []js.Value) any { | |||
| 579 | + | runConfirm() | |||
| 580 | + | return nil | |||
| 581 | + | }) | |||
| 582 | + | keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 583 | + | event := firstJSArg(args) | |||
| 584 | + | if !event.Truthy() { | |||
| 585 | + | return nil | |||
| 586 | + | } | |||
| 587 | + | switch strings.ToLower(event.Get("key").String()) { | |||
| 588 | + | case "escape": | |||
| 589 | + | event.Call("preventDefault") | |||
| 590 | + | event.Call("stopPropagation") | |||
| 591 | + | cleanup() | |||
| 592 | + | case "enter": | |||
| 593 | + | if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) { | |||
| 594 | + | event.Call("preventDefault") | |||
| 595 | + | event.Call("stopPropagation") | |||
| 596 | + | runConfirm() | |||
| 597 | + | } | |||
| 598 | + | } | |||
| 599 | + | return nil | |||
| 600 | + | }) | |||
| 601 | + | backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 602 | + | event := firstJSArg(args) | |||
| 603 | + | if event.Truthy() && event.Get("target").Equal(overlay) { | |||
| 604 | + | cleanup() | |||
| 605 | + | } | |||
| 606 | + | return nil | |||
| 607 | + | }) | |||
| 608 | + | ||||
| 609 | + | cancelButton.Call("addEventListener", "click", cancelFn) | |||
| 610 | + | confirmButton.Call("addEventListener", "click", confirmFn) | |||
| 611 | + | document := d.document | |||
| 612 | + | document.Call("addEventListener", "keydown", keyFn) | |||
| 613 | + | overlay.Call("addEventListener", "click", backdropFn) | |||
| 614 | + | d.activeConfirmCleanup = cleanup | |||
| 615 | + | body.Call("appendChild", overlay) | |||
| 616 | + | confirmButton.Call("focus") | |||
| 617 | + | return true | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool { | |||
| 621 | + | if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() { | |||
| 622 | + | return false | |||
| 623 | + | } | |||
| 624 | + | active := document.Get("activeElement") | |||
| 625 | + | if !active.Truthy() { | |||
| 626 | + | return true | |||
| 627 | + | } | |||
| 628 | + | if active.Equal(confirmButton) { | |||
| 629 | + | return true | |||
| 630 | + | } | |||
| 631 | + | if dialog.Call("contains", active).Bool() { | |||
| 632 | + | return false | |||
| 633 | + | } | |||
| 634 | + | return true | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | func (d *browserDOM) handleKeyboard(event js.Value) { | |||
| 638 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |||
| 639 | + | return | |||
| 640 | + | } | |||
| 641 | + | if d.isHistoryRoute() { | |||
| 642 | + | d.handleHistoryKeyboard(event) | |||
| 643 | + | return | |||
| 644 | + | } | |||
| 645 | + | if d.isFavoritesRoute() { | |||
| 646 | + | d.handleFavoritesKeyboard(event) | |||
| 647 | + | } | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | func (d *browserDOM) handleHistoryKeyboard(event js.Value) { | |||
| 651 | + | if d == nil || !d.target.Truthy() || !event.Truthy() { | |||
| 652 | + | return | |||
| 653 | + | } | |||
| 654 | + | key := strings.ToLower(event.Get("key").String()) | |||
| 655 | + | target := event.Get("target") | |||
| 656 | + | if isEditableTarget(target) && key != "escape" { | |||
| 657 | + | return | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | switch key { | |||
| 661 | + | case "/": | |||
| 662 | + | if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() { | |||
| 663 | + | event.Call("preventDefault") | |||
| 664 | + | input.Call("focus") | |||
| 665 | + | } | |||
| 666 | + | case "j", "arrowdown": | |||
| 667 | + | event.Call("preventDefault") | |||
| 668 | + | d.focusHistoryRow(1) | |||
| 669 | + | case "k", "arrowup": | |||
| 670 | + | event.Call("preventDefault") | |||
| 671 | + | d.focusHistoryRow(-1) | |||
| 672 | + | case "enter": | |||
| 673 | + | if row := d.activeHistoryRow(); row.Truthy() { | |||
| 674 | + | event.Call("preventDefault") | |||
| 675 | + | if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() { | |||
| 676 | + | link.Call("click") | |||
| 677 | + | } | |||
| 678 | + | } | |||
| 679 | + | case "d", "delete", "backspace": | |||
| 680 | + | // Delete the focused row through the same delegated click path as pointer users, | |||
| 681 | + | // including the data-sv-confirm guard on the delete button. | |||
| 682 | + | if row := d.activeHistoryRow(); row.Truthy() { | |||
| 683 | + | event.Call("preventDefault") | |||
| 684 | + | if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() { | |||
| 685 | + | button.Call("click") | |||
| 686 | + | } | |||
| 687 | + | } | |||
| 688 | + | case "escape": | |||
| 689 | + | if target.Truthy() && isEditableTarget(target) { | |||
| 690 | + | target.Call("blur") | |||
| 691 | + | } | |||
| 692 | + | } | |||
| 693 | + | } | |||
| 694 | + | ||||
| 695 | + | func (d *browserDOM) isHistoryRoute() bool { | |||
| 696 | + | return d.target.Call("querySelector", `[data-route="history"]`).Truthy() | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | func (d *browserDOM) isFavoritesRoute() bool { | |||
| 700 | + | return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy() | |||
| 701 | + | } | |||
| 702 | + | ||||
| 703 | + | func (d *browserDOM) handleFavoritesKeyboard(event js.Value) { | |||
| 704 | + | key := strings.ToLower(event.Get("key").String()) | |||
| 705 | + | target := event.Get("target") | |||
| 706 | + | if isEditableTarget(target) && key != "escape" { | |||
| 707 | + | return | |||
| 708 | + | } | |||
| 709 | + | ||||
| 710 | + | switch key { | |||
| 711 | + | case "j", "arrowdown": | |||
| 712 | + | event.Call("preventDefault") | |||
| 713 | + | d.focusFavoriteRow(1) | |||
| 714 | + | case "k", "arrowup": | |||
| 715 | + | event.Call("preventDefault") | |||
| 716 | + | d.focusFavoriteRow(-1) | |||
| 717 | + | case "enter": | |||
| 718 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |||
| 719 | + | event.Call("preventDefault") | |||
| 720 | + | if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() { | |||
| 721 | + | link.Call("click") | |||
| 722 | + | } | |||
| 723 | + | } | |||
| 724 | + | case "e": | |||
| 725 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |||
| 726 | + | event.Call("preventDefault") | |||
| 727 | + | if input := row.Call("querySelector", `[name="title"]`); input.Truthy() { | |||
| 728 | + | input.Call("focus") | |||
| 729 | + | } | |||
| 730 | + | } | |||
| 731 | + | case "d", "delete", "backspace": | |||
| 732 | + | if row := d.activeFavoriteRow(); row.Truthy() { | |||
| 733 | + | event.Call("preventDefault") | |||
| 734 | + | if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() { | |||
| 735 | + | button.Call("click") | |||
| 736 | + | } | |||
| 737 | + | } | |||
| 738 | + | case "escape": | |||
| 739 | + | if target.Truthy() && isEditableTarget(target) { | |||
| 740 | + | target.Call("blur") | |||
| 741 | + | } | |||
| 742 | + | } | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | func isEditableTarget(target js.Value) bool { | |||
| 746 | + | if !target.Truthy() { | |||
| 747 | + | return false | |||
| 748 | + | } | |||
| 749 | + | tag := strings.ToLower(target.Get("tagName").String()) | |||
| 750 | + | role := strings.ToLower(target.Get("role").String()) | |||
| 751 | + | return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" || | |||
| 752 | + | (tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" || | |||
| 753 | + | target.Get("isContentEditable").Bool() | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | func (d *browserDOM) focusHistoryRow(delta int) { | |||
| 757 | + | d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta) | |||
| 758 | + | } | |||
| 759 | + | ||||
| 760 | + | func (d *browserDOM) activeHistoryRow() js.Value { | |||
| 761 | + | return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex") | |||
| 762 | + | } | |||
| 763 | + | ||||
| 764 | + | func (d *browserDOM) focusFavoriteRow(delta int) { | |||
| 765 | + | d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta) | |||
| 766 | + | } | |||
| 767 | + | ||||
| 768 | + | func (d *browserDOM) activeFavoriteRow() js.Value { | |||
| 769 | + | return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex") | |||
| 770 | + | } | |||
| 771 | + | ||||
| 772 | + | func (d *browserDOM) focusRow(selector, datasetKey string, delta int) { | |||
| 773 | + | rows := d.target.Call("querySelectorAll", selector) | |||
| 774 | + | length := rows.Get("length").Int() | |||
| 775 | + | if length == 0 { | |||
| 776 | + | return | |||
| 777 | + | } | |||
| 778 | + | idx := d.activeIndex(datasetKey) | |||
| 779 | + | if idx < 0 { | |||
| 780 | + | idx = 0 | |||
| 781 | + | } else { | |||
| 782 | + | idx += delta | |||
| 783 | + | } | |||
| 784 | + | if idx < 0 { | |||
| 785 | + | idx = 0 | |||
| 786 | + | } | |||
| 787 | + | if idx >= length { | |||
| 788 | + | idx = length - 1 | |||
| 789 | + | } | |||
| 790 | + | for i := 0; i < length; i++ { | |||
| 791 | + | rows.Index(i).Get("classList").Call("remove", "sv-focused") | |||
| 792 | + | } | |||
| 793 | + | row := rows.Index(idx) | |||
| 794 | + | row.Get("classList").Call("add", "sv-focused") | |||
| 795 | + | d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx)) | |||
| 796 | + | row.Call("scrollIntoView", map[string]any{"block": "nearest"}) | |||
| 797 | + | } | |||
| 798 | + | ||||
| 799 | + | func (d *browserDOM) activeRow(selector, datasetKey string) js.Value { | |||
| 800 | + | rows := d.target.Call("querySelectorAll", selector) | |||
| 801 | + | length := rows.Get("length").Int() | |||
| 802 | + | if length <= 0 { | |||
| 803 | + | return js.Value{} | |||
| 804 | + | } | |||
| 805 | + | idx := d.activeIndex(datasetKey) | |||
| 806 | + | if idx < 0 || idx >= length { | |||
| 807 | + | return js.Value{} | |||
| 808 | + | } | |||
| 809 | + | return rows.Index(idx) | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | func (d *browserDOM) activeIndex(datasetKey string) int { | |||
| 813 | + | value := d.target.Get("dataset").Get(datasetKey).String() | |||
| 814 | + | idx, err := strconv.Atoi(value) | |||
| 815 | + | if err != nil { | |||
| 816 | + | return -1 | |||
| 817 | + | } | |||
| 818 | + | return idx | |||
| 819 | + | } | |||
A internal/ui/systemviews/favorite_actions.go +311 -0
| @@ -0,0 +1,311 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net/url" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | favoriteActionCreate = "favorite.create" | |||
| 16 | + | favoriteActionUpdate = "favorite.update" | |||
| 17 | + | favoriteActionDelete = "favorite.delete" | |||
| 18 | + | favoriteActionFilterFolder = "favorite.filterFolder" | |||
| 19 | + | favoriteActionFilterTag = "favorite.filterTag" | |||
| 20 | + | favoriteActionClearFilters = "favorite.clearFilters" | |||
| 21 | + | folderActionCreate = "folder.create" | |||
| 22 | + | folderActionUpdate = "folder.update" | |||
| 23 | + | folderActionDelete = "folder.delete" | |||
| 24 | + | tagActionCreate = "tag.create" | |||
| 25 | + | tagActionUpdate = "tag.update" | |||
| 26 | + | tagActionDelete = "tag.delete" | |||
| 27 | + | tagActionAssign = "tag.assign" | |||
| 28 | + | tagActionRemove = "tag.remove" | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | |||
| 32 | + | func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 33 | + | if a.deps.Favorites == nil { | |||
| 34 | + | return fmt.Errorf("favorites service not configured") | |||
| 35 | + | } | |||
| 36 | + | data := event.Data | |||
| 37 | + | switch event.Action { | |||
| 38 | + | case favoriteActionCreate: | |||
| 39 | + | folderID, err := optionalFolderID(data["folder_id"]) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | favoriteURL, err := validateFavoriteURL(data["url"]) | |||
| 44 | + | if err != nil { | |||
| 45 | + | return err | |||
| 46 | + | } | |||
| 47 | + | tags, err := optionalTagIDs(data["tags"]) | |||
| 48 | + | if err != nil { | |||
| 49 | + | return err | |||
| 50 | + | } | |||
| 51 | + | favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{ | |||
| 52 | + | URL: favoriteURL, | |||
| 53 | + | Title: strings.TrimSpace(data["title"]), | |||
| 54 | + | FolderID: folderID, | |||
| 55 | + | Tags: tags, | |||
| 56 | + | }) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite) | |||
| 61 | + | case favoriteActionUpdate: | |||
| 62 | + | id, err := parsePositiveInt64(data["id"], "favorite id") | |||
| 63 | + | if err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | folderID, err := optionalFolderID(data["folder_id"]) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | shortcut, err := optionalShortcut(data["shortcut_key"]) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{ | |||
| 75 | + | ID: entity.FavoriteID(id), | |||
| 76 | + | Title: strings.TrimSpace(data["title"]), | |||
| 77 | + | FaviconURL: strings.TrimSpace(data["favicon_url"]), | |||
| 78 | + | FolderID: folderID, | |||
| 79 | + | ShortcutKey: shortcut, | |||
| 80 | + | }) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite) | |||
| 85 | + | case favoriteActionDelete: | |||
| 86 | + | id, err := parsePositiveInt64(data["id"], "favorite id") | |||
| 87 | + | if err != nil { | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil { | |||
| 91 | + | return err | |||
| 92 | + | } | |||
| 93 | + | a.favoritesNotice = "Deleted favorite" | |||
| 94 | + | case favoriteActionFilterFolder: | |||
| 95 | + | folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id")) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return err | |||
| 98 | + | } | |||
| 99 | + | a.favoriteFolderFilter = folderID | |||
| 100 | + | a.favoriteTagFilter = nil | |||
| 101 | + | a.favoritesNotice = "" | |||
| 102 | + | case favoriteActionFilterTag: | |||
| 103 | + | tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") | |||
| 104 | + | if err != nil { | |||
| 105 | + | return err | |||
| 106 | + | } | |||
| 107 | + | id := entity.TagID(tagID) | |||
| 108 | + | a.favoriteTagFilter = &id | |||
| 109 | + | a.favoriteFolderFilter = nil | |||
| 110 | + | a.favoritesNotice = "" | |||
| 111 | + | case favoriteActionClearFilters: | |||
| 112 | + | a.favoriteFolderFilter = nil | |||
| 113 | + | a.favoriteTagFilter = nil | |||
| 114 | + | a.favoritesNotice = "" | |||
| 115 | + | case folderActionCreate: | |||
| 116 | + | name := strings.TrimSpace(data["name"]) | |||
| 117 | + | if name == "" { | |||
| 118 | + | return fmt.Errorf("folder name is required") | |||
| 119 | + | } | |||
| 120 | + | folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | a.favoritesNotice = "Created folder " + folderDisplayName(folder) | |||
| 125 | + | case folderActionUpdate: | |||
| 126 | + | id, err := parsePositiveInt64(data["id"], "folder id") | |||
| 127 | + | if err != nil { | |||
| 128 | + | return err | |||
| 129 | + | } | |||
| 130 | + | name := strings.TrimSpace(data["name"]) | |||
| 131 | + | if name == "" { | |||
| 132 | + | return fmt.Errorf("folder name is required") | |||
| 133 | + | } | |||
| 134 | + | if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil { | |||
| 135 | + | return err | |||
| 136 | + | } | |||
| 137 | + | a.favoritesNotice = "Saved folder " + name | |||
| 138 | + | case folderActionDelete: | |||
| 139 | + | id, err := parsePositiveInt64(data["id"], "folder id") | |||
| 140 | + | if err != nil { | |||
| 141 | + | return err | |||
| 142 | + | } | |||
| 143 | + | if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil { | |||
| 144 | + | return err | |||
| 145 | + | } | |||
| 146 | + | a.favoriteFolderFilter = nil | |||
| 147 | + | a.favoritesNotice = "Deleted folder" | |||
| 148 | + | case tagActionCreate: | |||
| 149 | + | name := strings.TrimSpace(data["name"]) | |||
| 150 | + | if name == "" { | |||
| 151 | + | return fmt.Errorf("tag name is required") | |||
| 152 | + | } | |||
| 153 | + | tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"])) | |||
| 154 | + | if err != nil { | |||
| 155 | + | return err | |||
| 156 | + | } | |||
| 157 | + | a.favoritesNotice = "Created tag " + tag.Name | |||
| 158 | + | case tagActionUpdate: | |||
| 159 | + | id, err := parsePositiveInt64(data["id"], "tag id") | |||
| 160 | + | if err != nil { | |||
| 161 | + | return err | |||
| 162 | + | } | |||
| 163 | + | name := strings.TrimSpace(data["name"]) | |||
| 164 | + | if name == "" { | |||
| 165 | + | return fmt.Errorf("tag name is required") | |||
| 166 | + | } | |||
| 167 | + | if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil { | |||
| 168 | + | return err | |||
| 169 | + | } | |||
| 170 | + | a.favoritesNotice = "Saved tag " + name | |||
| 171 | + | case tagActionDelete: | |||
| 172 | + | id, err := parsePositiveInt64(data["id"], "tag id") | |||
| 173 | + | if err != nil { | |||
| 174 | + | return err | |||
| 175 | + | } | |||
| 176 | + | if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil { | |||
| 177 | + | return err | |||
| 178 | + | } | |||
| 179 | + | a.favoriteTagFilter = nil | |||
| 180 | + | a.favoritesNotice = "Deleted tag" | |||
| 181 | + | case tagActionAssign, tagActionRemove: | |||
| 182 | + | favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id") | |||
| 183 | + | if err != nil { | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id") | |||
| 187 | + | if err != nil { | |||
| 188 | + | return err | |||
| 189 | + | } | |||
| 190 | + | if event.Action == tagActionAssign { | |||
| 191 | + | if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil { | |||
| 192 | + | return err | |||
| 193 | + | } | |||
| 194 | + | a.favoritesNotice = "Assigned tag" | |||
| 195 | + | } else { | |||
| 196 | + | if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil { | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | a.favoritesNotice = "Removed tag" | |||
| 200 | + | } | |||
| 201 | + | default: | |||
| 202 | + | return fmt.Errorf("unknown favorite action: %q", event.Action) | |||
| 203 | + | } | |||
| 204 | + | return nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func validateFavoriteURL(raw string) (string, error) { | |||
| 208 | + | favoriteURL := strings.TrimSpace(raw) | |||
| 209 | + | if favoriteURL == "" { | |||
| 210 | + | return "", fmt.Errorf("favorite URL is required") | |||
| 211 | + | } | |||
| 212 | + | parsed, err := url.Parse(favoriteURL) | |||
| 213 | + | if err != nil || parsed.Scheme == "" { | |||
| 214 | + | return "", fmt.Errorf("favorite URL must be absolute") | |||
| 215 | + | } | |||
| 216 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 217 | + | case "http", "https": | |||
| 218 | + | if parsed.Host == "" { | |||
| 219 | + | return "", fmt.Errorf("favorite URL host is required") | |||
| 220 | + | } | |||
| 221 | + | case "dumb": | |||
| 222 | + | if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown { | |||
| 223 | + | return favoriteURL, nil | |||
| 224 | + | } | |||
| 225 | + | return "", fmt.Errorf("favorite URL must target a known systemview route") | |||
| 226 | + | default: | |||
| 227 | + | return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") | |||
| 228 | + | } | |||
| 229 | + | return favoriteURL, nil | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func firstActionValue(data map[string]string, keys ...string) string { | |||
| 233 | + | for _, key := range keys { | |||
| 234 | + | if value, ok := data[key]; ok { | |||
| 235 | + | return value | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | return "" | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func parsePositiveInt64(raw, label string) (int64, error) { | |||
| 242 | + | id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return 0, fmt.Errorf("invalid %s: %w", label, err) | |||
| 245 | + | } | |||
| 246 | + | if id <= 0 { | |||
| 247 | + | return 0, fmt.Errorf("%s must be a positive integer", label) | |||
| 248 | + | } | |||
| 249 | + | return id, nil | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func optionalTagIDs(raw string) ([]entity.TagID, error) { | |||
| 253 | + | raw = strings.TrimSpace(raw) | |||
| 254 | + | if raw == "" { | |||
| 255 | + | return nil, nil | |||
| 256 | + | } | |||
| 257 | + | parts := strings.Split(raw, ",") | |||
| 258 | + | ids := make([]entity.TagID, 0, len(parts)) | |||
| 259 | + | seen := make(map[entity.TagID]struct{}, len(parts)) | |||
| 260 | + | for _, part := range parts { | |||
| 261 | + | id, err := parsePositiveInt64(part, "tag id") | |||
| 262 | + | if err != nil { | |||
| 263 | + | return nil, err | |||
| 264 | + | } | |||
| 265 | + | tagID := entity.TagID(id) | |||
| 266 | + | if _, ok := seen[tagID]; ok { | |||
| 267 | + | continue | |||
| 268 | + | } | |||
| 269 | + | seen[tagID] = struct{}{} | |||
| 270 | + | ids = append(ids, tagID) | |||
| 271 | + | } | |||
| 272 | + | return ids, nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // optionalFolderID returns nil for empty or "root" input, meaning no folder constraint; | |||
| 276 | + | // otherwise it parses a positive folder id into a non-nil *entity.FolderID. | |||
| 277 | + | func optionalFolderID(raw string) (*entity.FolderID, error) { | |||
| 278 | + | raw = strings.TrimSpace(raw) | |||
| 279 | + | if raw == "" || raw == "root" { | |||
| 280 | + | return nil, nil | |||
| 281 | + | } | |||
| 282 | + | id, err := parsePositiveInt64(raw, "folder id") | |||
| 283 | + | if err != nil { | |||
| 284 | + | return nil, err | |||
| 285 | + | } | |||
| 286 | + | folderID := entity.FolderID(id) | |||
| 287 | + | return &folderID, nil | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter | |||
| 291 | + | // favorites in the root folder; other inputs delegate to optionalFolderID. | |||
| 292 | + | func filterFolderID(raw string) (*entity.FolderID, error) { | |||
| 293 | + | if strings.TrimSpace(raw) == "root" { | |||
| 294 | + | root := entity.FolderID(0) | |||
| 295 | + | return &root, nil | |||
| 296 | + | } | |||
| 297 | + | return optionalFolderID(raw) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | // optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut. | |||
| 301 | + | func optionalShortcut(raw string) (*int, error) { | |||
| 302 | + | raw = strings.TrimSpace(raw) | |||
| 303 | + | if raw == "" { | |||
| 304 | + | return nil, nil | |||
| 305 | + | } | |||
| 306 | + | value, err := strconv.Atoi(raw) | |||
| 307 | + | if err != nil || value < 1 || value > 9 { | |||
| 308 | + | return nil, fmt.Errorf("shortcut key must be 1-9") | |||
| 309 | + | } | |||
| 310 | + | return &value, nil | |||
| 311 | + | } | |||
A internal/ui/systemviews/favorites.templ +218 -0
| @@ -0,0 +1,218 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | templ FavoritesView(data favoritesRenderData) { | |||
| 9 | + | @Alert("success", data.Notice) | |||
| 10 | + | @Alert("error", data.Error) | |||
| 11 | + | @Section("sv-favorites-controls", "Manage favorites") { | |||
| 12 | + | @FavoritesControls(data) | |||
| 13 | + | } | |||
| 14 | + | @Section("sv-favorites-list", "Favorites") { | |||
| 15 | + | @Meta(favoritesSummary(data)) | |||
| 16 | + | if len(data.Favorites) == 0 { | |||
| 17 | + | @EmptyState("No favorites") | |||
| 18 | + | } else { | |||
| 19 | + | <ul class="sv-list"> | |||
| 20 | + | for _, favorite := range data.Favorites { | |||
| 21 | + | if favorite != nil { | |||
| 22 | + | @FavoriteItem(favorite, data.Folders, data.Tags) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | </ul> | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | @Section("sv-folders-list", "Folders") { | |||
| 29 | + | @Meta(foldersSummary(data)) | |||
| 30 | + | @FolderCreateForm() | |||
| 31 | + | if len(data.Folders) == 0 { | |||
| 32 | + | @EmptyState("No folders") | |||
| 33 | + | } else { | |||
| 34 | + | <ul class="sv-list"> | |||
| 35 | + | for _, folder := range data.Folders { | |||
| 36 | + | if folder != nil { | |||
| 37 | + | @FolderItem(folder) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | </ul> | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | @Section("sv-tags-list", "Tags") { | |||
| 44 | + | @Meta(tagsSummary(data)) | |||
| 45 | + | @TagCreateForm() | |||
| 46 | + | if len(data.Tags) == 0 { | |||
| 47 | + | @EmptyState("No tags") | |||
| 48 | + | } else { | |||
| 49 | + | <ul class="sv-list"> | |||
| 50 | + | for _, tag := range data.Tags { | |||
| 51 | + | if tag != nil { | |||
| 52 | + | @TagItem(tag) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | </ul> | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | templ FavoritesControls(data favoritesRenderData) { | |||
| 61 | + | <form class="sv-card-form" data-sv-action="favorite.create"> | |||
| 62 | + | <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label> | |||
| 63 | + | <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label> | |||
| 64 | + | @FolderSelect("folder_id", data.Folders, nil, "Folder") | |||
| 65 | + | @FavoriteCreateTagPicker(data.Tags) | |||
| 66 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button> | |||
| 67 | + | </form> | |||
| 68 | + | <div class="sv-filter-panel"> | |||
| 69 | + | <div class="sv-button-row"> | |||
| 70 | + | <span class="sv-meta">Folders</span> | |||
| 71 | + | <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button> | |||
| 72 | + | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button> | |||
| 73 | + | for _, folder := range data.Folders { | |||
| 74 | + | if folder != nil { | |||
| 75 | + | <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button> | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | </div> | |||
| 79 | + | <div class="sv-button-row"> | |||
| 80 | + | <span class="sv-meta">Tags</span> | |||
| 81 | + | for _, tag := range data.Tags { | |||
| 82 | + | if tag != nil { | |||
| 83 | + | <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | |||
| 84 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name } | |||
| 85 | + | </button> | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | </div> | |||
| 89 | + | </div> | |||
| 90 | + | <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p> | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | templ FavoriteCreateTagPicker(tags []*entity.Tag) { | |||
| 94 | + | if len(tags) > 0 { | |||
| 95 | + | <fieldset class="sv-tag-picker"> | |||
| 96 | + | <legend>Tags</legend> | |||
| 97 | + | <div class="sv-tag-controls"> | |||
| 98 | + | for _, tag := range tags { | |||
| 99 | + | if tag != nil { | |||
| 100 | + | <label class="sv-tag-toggle"> | |||
| 101 | + | <input type="checkbox" name="tags" value={ fmt.Sprintf("%d", tag.ID) }/> | |||
| 102 | + | <span><span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }</span> | |||
| 103 | + | </label> | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | </div> | |||
| 107 | + | </fieldset> | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) { | |||
| 112 | + | <li class="sv-list-row"> | |||
| 113 | + | <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }> | |||
| 114 | + | <div class="sv-favorite-header"> | |||
| 115 | + | <div> | |||
| 116 | + | <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a> | |||
| 117 | + | <p class="sv-history-url">{ favorite.URL }</p> | |||
| 118 | + | if favoriteMetaText(favorite, folders) != "" { | |||
| 119 | + | <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p> | |||
| 120 | + | } | |||
| 121 | + | </div> | |||
| 122 | + | <div class="sv-history-row-actions"> | |||
| 123 | + | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a> | |||
| 124 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button> | |||
| 125 | + | </div> | |||
| 126 | + | </div> | |||
| 127 | + | @FavoriteEditForm(favorite, folders) | |||
| 128 | + | @FavoriteTagControls(favorite, tags) | |||
| 129 | + | </article> | |||
| 130 | + | </li> | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) { | |||
| 134 | + | <form class="sv-inline-form" data-sv-action="favorite.update"> | |||
| 135 | + | <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/> | |||
| 136 | + | <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label> | |||
| 137 | + | <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label> | |||
| 138 | + | @FolderSelect("folder_id", folders, favorite.FolderID, "Folder") | |||
| 139 | + | @ShortcutSelect(favorite.ShortcutKey) | |||
| 140 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |||
| 141 | + | </form> | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) { | |||
| 145 | + | if len(tags) > 0 { | |||
| 146 | + | <div class="sv-tag-controls"> | |||
| 147 | + | <span class="sv-meta">Tags</span> | |||
| 148 | + | for _, tag := range tags { | |||
| 149 | + | if tag != nil { | |||
| 150 | + | <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }> | |||
| 151 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) } | |||
| 152 | + | </button> | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | </div> | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | templ FolderCreateForm() { | |||
| 160 | + | <form class="sv-inline-form" data-sv-action="folder.create"> | |||
| 161 | + | <label><span>Name</span><input name="name" type="text" required/></label> | |||
| 162 | + | <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label> | |||
| 163 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button> | |||
| 164 | + | </form> | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | templ FolderItem(folder *entity.Folder) { | |||
| 168 | + | <li class="sv-list-row"> | |||
| 169 | + | <form class="sv-inline-form" data-sv-action="folder.update"> | |||
| 170 | + | <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/> | |||
| 171 | + | <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label> | |||
| 172 | + | <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label> | |||
| 173 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |||
| 174 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button> | |||
| 175 | + | </form> | |||
| 176 | + | </li> | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | templ TagCreateForm() { | |||
| 180 | + | <form class="sv-inline-form" data-sv-action="tag.create"> | |||
| 181 | + | <label><span>Name</span><input name="name" type="text" required/></label> | |||
| 182 | + | <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label> | |||
| 183 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconTag, "Create tag")</button> | |||
| 184 | + | </form> | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | templ TagItem(tag *entity.Tag) { | |||
| 188 | + | <li class="sv-list-row"> | |||
| 189 | + | <form class="sv-inline-form" data-sv-action="tag.update"> | |||
| 190 | + | <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/> | |||
| 191 | + | <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span> | |||
| 192 | + | <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label> | |||
| 193 | + | <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label> | |||
| 194 | + | <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button> | |||
| 195 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?" aria-label="Delete this tag">@IconOnly(iconTrash, "Delete this tag")</button> | |||
| 196 | + | </form> | |||
| 197 | + | </li> | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) { | |||
| 201 | + | <label><span>{ label }</span><select name={ name }> | |||
| 202 | + | <option value="">Unfiled</option> | |||
| 203 | + | for _, folder := range folders { | |||
| 204 | + | if folder != nil { | |||
| 205 | + | <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option> | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | </select></label> | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | templ ShortcutSelect(selected *int) { | |||
| 212 | + | <label><span>Shortcut</span><select name="shortcut_key"> | |||
| 213 | + | <option value="">None</option> | |||
| 214 | + | for i := 1; i <= 9; i++ { | |||
| 215 | + | <option value={ fmt.Sprintf("%d", i) } selected?={ shortcutSelected(selected, i) }>{ fmt.Sprintf("%d", i) }</option> | |||
| 216 | + | } | |||
| 217 | + | </select></label> | |||
| 218 | + | } | |||
A internal/ui/systemviews/favorites_templ.go +1416 -0 generated file: collapsed
A internal/ui/systemviews/favorites_view.go +193 -0
| @@ -0,0 +1,193 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const defaultTagColor = "#808080" | |||
| 11 | + | ||||
| 12 | + | type favoritesRenderData struct { | |||
| 13 | + | Favorites []*entity.Favorite | |||
| 14 | + | Folders []*entity.Folder | |||
| 15 | + | Tags []*entity.Tag | |||
| 16 | + | FolderFilter *entity.FolderID | |||
| 17 | + | TagFilter *entity.TagID | |||
| 18 | + | Notice string | |||
| 19 | + | Error string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func favoritesHTML(data favoritesRenderData) string { | |||
| 23 | + | return mustRenderComponent(FavoritesView(data)) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func favoritesDocumentTitle(data favoritesRenderData) string { | |||
| 27 | + | count := len(data.Favorites) | |||
| 28 | + | if data.FolderFilter != nil || data.TagFilter != nil { | |||
| 29 | + | if count == 1 { | |||
| 30 | + | return "Favorites — 1 filtered bookmark" | |||
| 31 | + | } | |||
| 32 | + | return fmt.Sprintf("Favorites — %d filtered bookmarks", count) | |||
| 33 | + | } | |||
| 34 | + | if count == 1 { | |||
| 35 | + | return "Favorites — 1 bookmark" | |||
| 36 | + | } | |||
| 37 | + | return fmt.Sprintf("Favorites — %d bookmarks", count) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func favoritesSummary(data favoritesRenderData) string { | |||
| 41 | + | return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags)) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func foldersSummary(data favoritesRenderData) string { | |||
| 45 | + | return fmt.Sprintf("%d folders", len(data.Folders)) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func tagsSummary(data favoritesRenderData) string { | |||
| 49 | + | return fmt.Sprintf("%d tags", len(data.Tags)) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func favoriteFilterClass(active bool) string { | |||
| 53 | + | classes := "sv-button sv-button-secondary" | |||
| 54 | + | if active { | |||
| 55 | + | classes += " sv-button-active" | |||
| 56 | + | } | |||
| 57 | + | return classes | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool { | |||
| 61 | + | return data.FolderFilter != nil && *data.FolderFilter == folderID | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func favoriteAllFilterActive(data favoritesRenderData) bool { | |||
| 65 | + | return data.FolderFilter == nil && data.TagFilter == nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool { | |||
| 69 | + | return data.TagFilter != nil && *data.TagFilter == tagID | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func favoriteItemLabel(favorite *entity.Favorite) string { | |||
| 73 | + | if favorite == nil { | |||
| 74 | + | return "" | |||
| 75 | + | } | |||
| 76 | + | label := strings.TrimSpace(favorite.Title) | |||
| 77 | + | if label == "" { | |||
| 78 | + | label = favorite.URL | |||
| 79 | + | } | |||
| 80 | + | return label | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string { | |||
| 84 | + | if favorite == nil { | |||
| 85 | + | return "" | |||
| 86 | + | } | |||
| 87 | + | parts := make([]string, 0, 2) | |||
| 88 | + | if favorite.ShortcutKey != nil { | |||
| 89 | + | parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey)) | |||
| 90 | + | } | |||
| 91 | + | if name := folderNameForID(folders, favorite.FolderID); name != "" { | |||
| 92 | + | parts = append(parts, "Folder "+name) | |||
| 93 | + | } | |||
| 94 | + | return strings.Join(parts, " · ") | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string { | |||
| 98 | + | if favorite != nil && tag != nil && favorite.HasTag(tag.ID) { | |||
| 99 | + | return tagActionRemove | |||
| 100 | + | } | |||
| 101 | + | return tagActionAssign | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string { | |||
| 105 | + | if tag == nil { | |||
| 106 | + | return "" | |||
| 107 | + | } | |||
| 108 | + | if favorite != nil && favorite.HasTag(tag.ID) { | |||
| 109 | + | return "✓ " + tag.Name | |||
| 110 | + | } | |||
| 111 | + | return "+ " + tag.Name | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string { | |||
| 115 | + | classes := "sv-button sv-button-secondary sv-tag-button" | |||
| 116 | + | if favorite != nil && tag != nil && favorite.HasTag(tag.ID) { | |||
| 117 | + | classes += " sv-button-active sv-tag-button-active" | |||
| 118 | + | } | |||
| 119 | + | return classes | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func safeTagColor(raw string) string { | |||
| 123 | + | value := strings.TrimSpace(raw) | |||
| 124 | + | if len(value) != 4 && len(value) != 7 { | |||
| 125 | + | return defaultTagColor | |||
| 126 | + | } | |||
| 127 | + | if value[0] != '#' { | |||
| 128 | + | return defaultTagColor | |||
| 129 | + | } | |||
| 130 | + | for _, ch := range value[1:] { | |||
| 131 | + | if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') { | |||
| 132 | + | continue | |||
| 133 | + | } | |||
| 134 | + | return defaultTagColor | |||
| 135 | + | } | |||
| 136 | + | return value | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func folderDisplayName(folder *entity.Folder) string { | |||
| 140 | + | if folder == nil { | |||
| 141 | + | return "" | |||
| 142 | + | } | |||
| 143 | + | if strings.TrimSpace(folder.Icon) != "" { | |||
| 144 | + | return folder.Icon + " " + folder.Name | |||
| 145 | + | } | |||
| 146 | + | return folder.Name | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string { | |||
| 150 | + | if id == nil { | |||
| 151 | + | return "Unfiled" | |||
| 152 | + | } | |||
| 153 | + | for _, folder := range folders { | |||
| 154 | + | if folder != nil && folder.ID == *id { | |||
| 155 | + | return folderDisplayName(folder) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | return "Unknown" | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool { | |||
| 162 | + | return selected != nil && *selected == folderID | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func shortcutSelected(selected *int, value int) bool { | |||
| 166 | + | return selected != nil && *selected == value | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite { | |||
| 170 | + | if folderID == nil && tagID == nil { | |||
| 171 | + | return favorites | |||
| 172 | + | } | |||
| 173 | + | out := make([]*entity.Favorite, 0, len(favorites)) | |||
| 174 | + | for _, favorite := range favorites { | |||
| 175 | + | if favorite == nil { | |||
| 176 | + | continue | |||
| 177 | + | } | |||
| 178 | + | if folderID != nil { | |||
| 179 | + | if *folderID == 0 { | |||
| 180 | + | if favorite.FolderID != nil { | |||
| 181 | + | continue | |||
| 182 | + | } | |||
| 183 | + | } else if favorite.FolderID == nil || *favorite.FolderID != *folderID { | |||
| 184 | + | continue | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | if tagID != nil && !favorite.HasTag(*tagID) { | |||
| 188 | + | continue | |||
| 189 | + | } | |||
| 190 | + | out = append(out, favorite) | |||
| 191 | + | } | |||
| 192 | + | return out | |||
| 193 | + | } | |||
A internal/ui/systemviews/history.templ +172 -0
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | templ HistoryView(data historyRenderData) { | |||
| 10 | + | @Alert("success", data.Notice) | |||
| 11 | + | @Alert("error", data.Error) | |||
| 12 | + | @Section("sv-history-controls", "Manage history") { | |||
| 13 | + | @HistoryControls(data) | |||
| 14 | + | } | |||
| 15 | + | @Section("sv-history-domains", "Top domains") { | |||
| 16 | + | @HistoryDomains(data) | |||
| 17 | + | } | |||
| 18 | + | @Section("sv-history-timeline", "Recent visits") { | |||
| 19 | + | @HistoryTimeline(data) | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | templ HistoryControls(data historyRenderData) { | |||
| 24 | + | <form class="sv-toolbar sv-history-search" data-sv-action="history.search"> | |||
| 25 | + | <label class="sv-search-label"> | |||
| 26 | + | <span>Search</span> | |||
| 27 | + | <input data-sv-history-search data-sv-autofocus name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off" autofocus/> | |||
| 28 | + | </label> | |||
| 29 | + | <button class="sv-button" type="submit">@ButtonIconLabel(iconSearch, "Search")</button> | |||
| 30 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">@ButtonIconLabel(iconX, "Clear")</button> | |||
| 31 | + | </form> | |||
| 32 | + | if showHistoryFilters(data) { | |||
| 33 | + | <div class="sv-active-filters"> | |||
| 34 | + | if data.Query != "" { | |||
| 35 | + | <span class="sv-filter-chip">Query: { data.Query }</span> | |||
| 36 | + | } | |||
| 37 | + | if data.DomainFilter != "" { | |||
| 38 | + | <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">@IconOnly(iconX, "Clear domain filter")</button></span> | |||
| 39 | + | } | |||
| 40 | + | </div> | |||
| 41 | + | } | |||
| 42 | + | <div class="sv-history-actions-panel"> | |||
| 43 | + | <div class="sv-button-row" aria-label="History pagination"> | |||
| 44 | + | <span class="sv-meta">{ historyShowingLabel(data) }</span> | |||
| 45 | + | </div> | |||
| 46 | + | <div class="sv-button-row" aria-label="History cleanup"> | |||
| 47 | + | <span class="sv-meta">Clean up</span> | |||
| 48 | + | for _, item := range historyCleanupItems() { | |||
| 49 | + | <button type="button" class="sv-danger-link" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button> | |||
| 50 | + | } | |||
| 51 | + | </div> | |||
| 52 | + | </div> | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | templ HistoryDomains(data historyRenderData) { | |||
| 56 | + | if data.Loading { | |||
| 57 | + | @LoadingPanel("Loading top domains…") | |||
| 58 | + | } else if len(data.Domains) == 0 { | |||
| 59 | + | @EmptyState("No domain statistics yet") | |||
| 60 | + | } else { | |||
| 61 | + | <div class="sv-domain-list"> | |||
| 62 | + | for _, domain := range data.Domains { | |||
| 63 | + | if domain != nil && domain.Domain != "" { | |||
| 64 | + | {{ domainKey := historyDomainActionKey(domain) }} | |||
| 65 | + | {{ displayDomain := historyDomainDisplayLabel(domain) }} | |||
| 66 | + | <span class="sv-domain-chip"> | |||
| 67 | + | <span class="sv-domain-chip-main"> | |||
| 68 | + | <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button> | |||
| 69 | + | <span class="sv-domain-meta">{ fmt.Sprintf("%dp · %dv", domain.PageCount, domain.TotalVisits) }</span> | |||
| 70 | + | </span> | |||
| 71 | + | <button type="button" class="sv-domain-delete" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }><span aria-hidden="true">×</span><span class="sv-sr-only">Delete history for { displayDomain }</span></button> | |||
| 72 | + | </span> | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | </div> | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | templ HistoryTimeline(data historyRenderData) { | |||
| 80 | + | if data.Loading { | |||
| 81 | + | @LoadingPanel("Loading recent visits…") | |||
| 82 | + | } else { | |||
| 83 | + | <div class="sv-history-timeline-list" data-sv-history-timeline> | |||
| 84 | + | {{ groups := groupHistoryEntries(data.Entries) }} | |||
| 85 | + | if len(groups) == 0 { | |||
| 86 | + | @EmptyState(historyTimelineEmptyMessage(data)) | |||
| 87 | + | } else { | |||
| 88 | + | @HistoryTimelineGroups(data.Entries, "") | |||
| 89 | + | } | |||
| 90 | + | @HistoryLoadMore(data) | |||
| 91 | + | </div> | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | templ HistoryTimelineAppend(data historyRenderData) { | |||
| 96 | + | @HistoryTimelineAppendGroups(data.Entries, data.AppendSkipFirstDate) | |||
| 97 | + | @HistoryLoadMore(data) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | templ HistoryTimelineGroups(entries []*entity.HistoryEntry, skipFirstDate string) { | |||
| 101 | + | {{ groups := groupHistoryEntries(entries) }} | |||
| 102 | + | for _, group := range groups { | |||
| 103 | + | @HistoryTimelineGroup(group, true) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | templ HistoryTimelineAppendGroups(entries []*entity.HistoryEntry, mergeFirstDate string) { | |||
| 108 | + | {{ groups := groupHistoryEntries(entries) }} | |||
| 109 | + | for index, group := range groups { | |||
| 110 | + | if index == 0 && group.Date == mergeFirstDate { | |||
| 111 | + | <div data-sv-history-merge-date={ group.Date }> | |||
| 112 | + | for _, entry := range group.Entries { | |||
| 113 | + | if entry != nil { | |||
| 114 | + | @HistoryItem(entry) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | </div> | |||
| 118 | + | } else { | |||
| 119 | + | @HistoryTimelineGroup(group, true) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | templ HistoryTimelineGroup(group historyTimelineGroup, showHeading bool) { | |||
| 125 | + | <section class="sv-timeline-group" data-sv-history-date={ group.Date }> | |||
| 126 | + | if showHeading { | |||
| 127 | + | <h3>{ group.Label }</h3> | |||
| 128 | + | } | |||
| 129 | + | <ul class="sv-list"> | |||
| 130 | + | for _, entry := range group.Entries { | |||
| 131 | + | if entry != nil { | |||
| 132 | + | @HistoryItem(entry) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | </ul> | |||
| 136 | + | </section> | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | templ HistoryLoadMore(data historyRenderData) { | |||
| 140 | + | <div class="sv-history-load-more" data-sv-history-load-more-container> | |||
| 141 | + | if showHistoryLoadMore(data) { | |||
| 142 | + | <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.loadMore" data-before={ historyLoadMoreCursor(data) } data-sv-history-load-more>Load older visits</button> | |||
| 143 | + | } else if strings.TrimSpace(data.Query) == "" && countHistoryEntries(data.Entries) > 0 { | |||
| 144 | + | <p class="sv-meta">End of history</p> | |||
| 145 | + | } | |||
| 146 | + | </div> | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | templ HistoryItem(entry *entity.HistoryEntry) { | |||
| 150 | + | <li class="sv-list-row"> | |||
| 151 | + | <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }> | |||
| 152 | + | <div class="sv-history-content"> | |||
| 153 | + | {{ faviconURL := historyItemFaviconURL(entry) }} | |||
| 154 | + | <span class={ historyFaviconClass(faviconURL) } aria-hidden="true"> | |||
| 155 | + | <span class="sv-history-favicon-fallback">{ historyFaviconFallback(entry) }</span> | |||
| 156 | + | if faviconURL != "" { | |||
| 157 | + | <img src={ templ.SafeURL(faviconURL) } alt="" width="24" height="24" loading="lazy" decoding="async" referrerpolicy="same-origin" onerror="this.parentElement.classList.remove('sv-history-favicon-has-image'); this.remove()"/> | |||
| 158 | + | } | |||
| 159 | + | </span> | |||
| 160 | + | <div class="sv-history-main"> | |||
| 161 | + | <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a> | |||
| 162 | + | <p class="sv-history-url" title={ entry.URL }>{ historyItemURL(entry) }</p> | |||
| 163 | + | <p class="sv-history-meta">{ historyItemMeta(entry) }</p> | |||
| 164 | + | </div> | |||
| 165 | + | </div> | |||
| 166 | + | <div class="sv-history-row-actions"> | |||
| 167 | + | <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a> | |||
| 168 | + | <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" aria-label="Delete this history entry" disabled?={ entry.ID <= 0 }>@IconOnly(iconTrash, "Delete this history entry")</button> | |||
| 169 | + | </div> | |||
| 170 | + | </article> | |||
| 171 | + | </li> | |||
| 172 | + | } | |||
A internal/ui/systemviews/history_actions.go +306 -0
| @@ -0,0 +1,306 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | historyActionSearch = "history.search" | |||
| 13 | + | historyActionClear = "history.clear" | |||
| 14 | + | historyActionFilterDomain = "history.filterDomain" | |||
| 15 | + | historyActionClearDomain = "history.clearDomain" | |||
| 16 | + | historyActionPage = "history.page" | |||
| 17 | + | historyActionLoadMore = "history.loadMore" | |||
| 18 | + | historyActionDeleteEntry = "history.deleteEntry" | |||
| 19 | + | historyActionDeleteRange = "history.deleteRange" | |||
| 20 | + | historyActionDeleteDomain = "history.deleteDomain" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // HandleDOMAction applies an action delegated from the browser DOM and refreshes | |||
| 24 | + | // the mounted view. It keeps UI orchestration in the systemview edge and calls | |||
| 25 | + | // only application ports for data changes. | |||
| 26 | + | func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 27 | + | if a == nil { | |||
| 28 | + | return fmt.Errorf("app is nil") | |||
| 29 | + | } | |||
| 30 | + | a.lockState() | |||
| 31 | + | if a.closed { | |||
| 32 | + | a.unlockState() | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | a.renderGeneration++ | |||
| 36 | + | if a.currentRoute == RouteUnknown || a.currentRoute == "" { | |||
| 37 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 38 | + | } | |||
| 39 | + | route := a.currentRoute | |||
| 40 | + | if route == RouteHistory && event.Action == historyActionLoadMore { | |||
| 41 | + | a.unlockState() | |||
| 42 | + | return a.handleHistoryLoadMore(ctx, event) | |||
| 43 | + | } | |||
| 44 | + | defer a.unlockState() | |||
| 45 | + | ||||
| 46 | + | switch route { | |||
| 47 | + | case RouteHistory: | |||
| 48 | + | a.historyError = "" | |||
| 49 | + | if err := a.handleHistoryAction(ctx, event); err != nil { | |||
| 50 | + | a.historyNotice = "" | |||
| 51 | + | a.historyError = err.Error() | |||
| 52 | + | } | |||
| 53 | + | if err := a.loadHistoryRoute(ctx); err != nil { | |||
| 54 | + | a.renderRouteError(err) | |||
| 55 | + | _ = a.mountRenderedHTML() | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | return a.mountRenderedHTML() | |||
| 59 | + | case RouteFavorites: | |||
| 60 | + | a.favoritesError = "" | |||
| 61 | + | if err := a.handleFavoriteAction(ctx, event); err != nil { | |||
| 62 | + | a.favoritesNotice = "" | |||
| 63 | + | a.favoritesError = err.Error() | |||
| 64 | + | } | |||
| 65 | + | if err := a.loadFavoritesRoute(ctx); err != nil { | |||
| 66 | + | a.renderRouteError(err) | |||
| 67 | + | _ = a.mountRenderedHTML() | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | return a.mountRenderedHTML() | |||
| 71 | + | case RouteConfig: | |||
| 72 | + | a.configError = "" | |||
| 73 | + | if err := a.handleConfigAction(ctx, event); err != nil { | |||
| 74 | + | a.configNotice = "" | |||
| 75 | + | a.configError = err.Error() | |||
| 76 | + | } | |||
| 77 | + | a.loadShellTheme(ctx) | |||
| 78 | + | if err := a.loadConfigRoute(ctx); err != nil { | |||
| 79 | + | a.renderRouteError(err) | |||
| 80 | + | _ = a.mountRenderedHTML() | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | return a.mountRenderedHTML() | |||
| 84 | + | default: | |||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | //nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place. | |||
| 90 | + | func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error { | |||
| 91 | + | if a.deps.History == nil { | |||
| 92 | + | return fmt.Errorf("history service not configured") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | data := event.Data | |||
| 96 | + | switch event.Action { | |||
| 97 | + | case historyActionSearch: | |||
| 98 | + | a.historyQuery = strings.TrimSpace(data["query"]) | |||
| 99 | + | a.historyDomainFilter = "" | |||
| 100 | + | a.resetHistoryWindowState() | |||
| 101 | + | a.historyNotice = "" | |||
| 102 | + | case historyActionClear: | |||
| 103 | + | a.historyQuery = "" | |||
| 104 | + | a.historyDomainFilter = "" | |||
| 105 | + | a.resetHistoryWindowState() | |||
| 106 | + | a.historyNotice = "" | |||
| 107 | + | case historyActionFilterDomain: | |||
| 108 | + | domain := strings.TrimSpace(data["domain"]) | |||
| 109 | + | if domain == "" { | |||
| 110 | + | return fmt.Errorf("domain is required") | |||
| 111 | + | } | |||
| 112 | + | a.historyDomainFilter = domain | |||
| 113 | + | a.historyQuery = "" | |||
| 114 | + | a.resetHistoryWindowState() | |||
| 115 | + | a.historyNotice = "" | |||
| 116 | + | case historyActionClearDomain: | |||
| 117 | + | a.historyDomainFilter = "" | |||
| 118 | + | a.resetHistoryWindowState() | |||
| 119 | + | a.historyNotice = "" | |||
| 120 | + | case historyActionPage: | |||
| 121 | + | offset, err := strconv.Atoi(strings.TrimSpace(data["offset"])) | |||
| 122 | + | if err != nil || offset < 0 { | |||
| 123 | + | return fmt.Errorf("invalid history offset") | |||
| 124 | + | } | |||
| 125 | + | a.resetHistoryWindowState() | |||
| 126 | + | a.historyOffset = offset | |||
| 127 | + | a.historyNotice = "" | |||
| 128 | + | case historyActionDeleteEntry: | |||
| 129 | + | id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64) | |||
| 130 | + | if err != nil || id <= 0 { | |||
| 131 | + | return fmt.Errorf("invalid history entry id") | |||
| 132 | + | } | |||
| 133 | + | if err := a.deps.History.DeleteEntry(ctx, id); err != nil { | |||
| 134 | + | return err | |||
| 135 | + | } | |||
| 136 | + | a.resetHistoryWindowState() | |||
| 137 | + | a.historyNotice = "Deleted history entry" | |||
| 138 | + | case historyActionDeleteRange: | |||
| 139 | + | rangeID := strings.TrimSpace(data["range"]) | |||
| 140 | + | if rangeID == "" { | |||
| 141 | + | return fmt.Errorf("history range is required") | |||
| 142 | + | } | |||
| 143 | + | if !isKnownHistoryCleanupRange(rangeID) { | |||
| 144 | + | return fmt.Errorf("invalid history range") | |||
| 145 | + | } | |||
| 146 | + | if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil { | |||
| 147 | + | return err | |||
| 148 | + | } | |||
| 149 | + | a.resetHistoryWindowState() | |||
| 150 | + | a.historyNotice = historyRangeNotice(rangeID) | |||
| 151 | + | case historyActionDeleteDomain: | |||
| 152 | + | domain := strings.TrimSpace(data["domain"]) | |||
| 153 | + | if domain == "" { | |||
| 154 | + | return fmt.Errorf("domain is required") | |||
| 155 | + | } | |||
| 156 | + | if err := a.deps.History.DeleteDomain(ctx, domain); err != nil { | |||
| 157 | + | return err | |||
| 158 | + | } | |||
| 159 | + | if strings.EqualFold(a.historyDomainFilter, domain) { | |||
| 160 | + | a.historyDomainFilter = "" | |||
| 161 | + | } | |||
| 162 | + | a.resetHistoryWindowState() | |||
| 163 | + | a.historyNotice = "Deleted history for " + domain | |||
| 164 | + | default: | |||
| 165 | + | return fmt.Errorf("unknown history action: %q", event.Action) | |||
| 166 | + | } | |||
| 167 | + | return nil | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error { | |||
| 171 | + | a.lockState() | |||
| 172 | + | if a.deps.History == nil { | |||
| 173 | + | a.unlockState() | |||
| 174 | + | return fmt.Errorf("history service not configured") | |||
| 175 | + | } | |||
| 176 | + | if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore { | |||
| 177 | + | a.unlockState() | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | before := a.historyWindowAfter | |||
| 181 | + | if requested := strings.TrimSpace(event.Data["before"]); requested != "" { | |||
| 182 | + | parsed, err := time.Parse(time.RFC3339Nano, requested) | |||
| 183 | + | if err != nil { | |||
| 184 | + | a.unlockState() | |||
| 185 | + | return fmt.Errorf("invalid history cursor") | |||
| 186 | + | } | |||
| 187 | + | if !before.IsZero() && !parsed.Equal(before) { | |||
| 188 | + | a.unlockState() | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | before = parsed | |||
| 192 | + | } | |||
| 193 | + | if before.IsZero() { | |||
| 194 | + | before = time.Now() | |||
| 195 | + | } | |||
| 196 | + | domain := a.historyDomainFilter | |||
| 197 | + | history := a.deps.History | |||
| 198 | + | dom := a.deps.DOM | |||
| 199 | + | a.unlockState() | |||
| 200 | + | ||||
| 201 | + | window, err := history.TimelineWindow(ctx, before, domain) | |||
| 202 | + | if err != nil { | |||
| 203 | + | return err | |||
| 204 | + | } | |||
| 205 | + | if window == nil { | |||
| 206 | + | a.lockState() | |||
| 207 | + | a.historyHasMore = false | |||
| 208 | + | a.unlockState() | |||
| 209 | + | return nil | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | a.lockState() | |||
| 213 | + | appendSkipFirstDate := lastHistoryDateKey(a.historyEntries) | |||
| 214 | + | a.historyEntries = append(a.historyEntries, window.Entries...) | |||
| 215 | + | a.historyWindowBefore = window.Before | |||
| 216 | + | a.historyWindowAfter = window.After | |||
| 217 | + | a.historyHasMore = window.HasMore | |||
| 218 | + | data := historyRenderData{ | |||
| 219 | + | Entries: window.Entries, | |||
| 220 | + | Stats: a.historyStats, | |||
| 221 | + | Domains: a.historyDomainStats, | |||
| 222 | + | Query: a.historyQuery, | |||
| 223 | + | DomainFilter: a.historyDomainFilter, | |||
| 224 | + | Offset: a.historyOffset, | |||
| 225 | + | Limit: historyTimelineLimit, | |||
| 226 | + | WindowBefore: a.historyWindowBefore, | |||
| 227 | + | WindowAfter: a.historyWindowAfter, | |||
| 228 | + | HasMore: a.historyHasMore, | |||
| 229 | + | AppendSkipFirstDate: appendSkipFirstDate, | |||
| 230 | + | Notice: a.historyNotice, | |||
| 231 | + | Error: a.historyError, | |||
| 232 | + | } | |||
| 233 | + | fragment := historyTimelineAppendHTML(data) | |||
| 234 | + | var fallbackHTML string | |||
| 235 | + | if _, ok := dom.(DOMHistoryTimelineAppender); !ok { | |||
| 236 | + | fullData := data | |||
| 237 | + | fullData.Entries = a.historyEntries | |||
| 238 | + | title := historyDocumentTitle(fullData) | |||
| 239 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |||
| 240 | + | route: RouteHistory, | |||
| 241 | + | title: title, | |||
| 242 | + | subtitle: "History", | |||
| 243 | + | subtitleDetail: historyTitleDetail(fullData), | |||
| 244 | + | body: historyHTML(fullData), | |||
| 245 | + | }, a.shellTheme) | |||
| 246 | + | fallbackHTML = a.renderedHTML | |||
| 247 | + | } | |||
| 248 | + | a.unlockState() | |||
| 249 | + | ||||
| 250 | + | if appender, ok := dom.(DOMHistoryTimelineAppender); ok { | |||
| 251 | + | return appender.AppendHistoryTimeline(fragment) | |||
| 252 | + | } | |||
| 253 | + | return a.mountHTML(fallbackHTML) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (a *App) resetHistoryWindowState() { | |||
| 257 | + | a.historyOffset = 0 | |||
| 258 | + | a.historyWindowBefore = time.Time{} | |||
| 259 | + | a.historyWindowAfter = time.Time{} | |||
| 260 | + | a.historyHasMore = false | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func (a *App) mountRenderedHTML() error { | |||
| 264 | + | return a.mountHTML(a.renderedHTML) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (a *App) mountHTML(html string) error { | |||
| 268 | + | if a == nil || a.deps.DOM == nil { | |||
| 269 | + | return nil | |||
| 270 | + | } | |||
| 271 | + | return a.deps.DOM.Mount(html) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func (a *App) mountHTMLIfCurrent(ctx context.Context, html string, generation uint64) error { | |||
| 275 | + | if a == nil || a.deps.DOM == nil { | |||
| 276 | + | return nil | |||
| 277 | + | } | |||
| 278 | + | if ctx != nil && ctx.Err() != nil { | |||
| 279 | + | return nil | |||
| 280 | + | } | |||
| 281 | + | a.lockState() | |||
| 282 | + | current := !a.closed && a.renderGeneration == generation | |||
| 283 | + | a.unlockState() | |||
| 284 | + | if !current { | |||
| 285 | + | return nil | |||
| 286 | + | } | |||
| 287 | + | return a.deps.DOM.Mount(html) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func isKnownHistoryCleanupRange(rangeID string) bool { | |||
| 291 | + | for _, item := range historyCleanupItems() { | |||
| 292 | + | if item.RangeID == rangeID { | |||
| 293 | + | return true | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | return false | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func historyRangeNotice(rangeID string) string { | |||
| 300 | + | for _, item := range historyCleanupItems() { | |||
| 301 | + | if item.RangeID == rangeID { | |||
| 302 | + | return item.Notice | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | return "Deleted history" | |||
| 306 | + | } | |||
A internal/ui/systemviews/history_templ.go +978 -0 generated file: collapsed
A internal/ui/systemviews/history_view.go +372 -0
| @@ -0,0 +1,372 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "net/url" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | browserurl "github.com/bnema/dumber/internal/domain/url" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const historyURLDisplayMaxRunes = 180 | |||
| 14 | + | ||||
| 15 | + | type historyRenderData struct { | |||
| 16 | + | Entries []*entity.HistoryEntry | |||
| 17 | + | Stats *entity.HistoryStats | |||
| 18 | + | Analytics *entity.HistoryAnalytics | |||
| 19 | + | Domains []*entity.DomainStat | |||
| 20 | + | Query string | |||
| 21 | + | DomainFilter string | |||
| 22 | + | Offset int | |||
| 23 | + | Limit int | |||
| 24 | + | WindowBefore time.Time | |||
| 25 | + | WindowAfter time.Time | |||
| 26 | + | HasMore bool | |||
| 27 | + | AppendSkipFirstDate string | |||
| 28 | + | Notice string | |||
| 29 | + | Error string | |||
| 30 | + | Loading bool | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type historyTimelineGroup struct { | |||
| 34 | + | Date string | |||
| 35 | + | Label string | |||
| 36 | + | Entries []*entity.HistoryEntry | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type historyCleanupItem struct { | |||
| 40 | + | Label string | |||
| 41 | + | RangeID string | |||
| 42 | + | Confirm string | |||
| 43 | + | Notice string | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func historyHTML(data historyRenderData) string { | |||
| 47 | + | return mustRenderComponent(HistoryView(data)) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func historyTimelineAppendHTML(data historyRenderData) string { | |||
| 51 | + | return mustRenderComponent(HistoryTimelineAppend(data)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func historyDocumentTitle(data historyRenderData) string { | |||
| 55 | + | return "History " + historyTitleDetail(data) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func historyTitleDetail(data historyRenderData) string { | |||
| 59 | + | if data.Loading { | |||
| 60 | + | return "- Loading" | |||
| 61 | + | } | |||
| 62 | + | entries, visits, days := historySummaryValues(data) | |||
| 63 | + | return fmt.Sprintf( | |||
| 64 | + | "- %s, %s, %s", | |||
| 65 | + | historyCountLabel(entries, "entry", "entries"), | |||
| 66 | + | historyCountLabel(visits, "visit", "visits"), | |||
| 67 | + | historyCountLabel(days, "day", "days"), | |||
| 68 | + | ) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func historyCountLabel(count int64, singular, plural string) string { | |||
| 72 | + | label := plural | |||
| 73 | + | if count == 1 { | |||
| 74 | + | label = singular | |||
| 75 | + | } | |||
| 76 | + | return fmt.Sprintf("%d %s", count, label) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | var historyCleanupRanges = []historyCleanupItem{ | |||
| 80 | + | {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"}, | |||
| 81 | + | {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"}, | |||
| 82 | + | {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"}, | |||
| 83 | + | {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"}, | |||
| 84 | + | {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"}, | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func historyCleanupItems() []historyCleanupItem { | |||
| 88 | + | return historyCleanupRanges | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func historyLimit(data historyRenderData) int { | |||
| 92 | + | if data.Limit <= 0 { | |||
| 93 | + | return 0 | |||
| 94 | + | } | |||
| 95 | + | return data.Limit | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func historyIsPaginated(data historyRenderData) bool { | |||
| 99 | + | return historyLimit(data) > 0 | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func disableHistoryNext(data historyRenderData) bool { | |||
| 103 | + | return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func showHistoryFilters(data historyRenderData) bool { | |||
| 107 | + | return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func historyShowingLabel(data historyRenderData) string { | |||
| 111 | + | if data.Loading { | |||
| 112 | + | return "Loading history…" | |||
| 113 | + | } | |||
| 114 | + | count := countHistoryEntries(data.Entries) | |||
| 115 | + | if strings.TrimSpace(data.Query) != "" { | |||
| 116 | + | return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count)) | |||
| 117 | + | } | |||
| 118 | + | if strings.TrimSpace(data.DomainFilter) != "" { | |||
| 119 | + | return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count)) | |||
| 120 | + | } | |||
| 121 | + | return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count)) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func pluralSuffix(count int) string { | |||
| 125 | + | if count == 1 { | |||
| 126 | + | return "" | |||
| 127 | + | } | |||
| 128 | + | return "s" | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func historyLoadMoreCursor(data historyRenderData) string { | |||
| 132 | + | if data.WindowAfter.IsZero() { | |||
| 133 | + | return "" | |||
| 134 | + | } | |||
| 135 | + | return data.WindowAfter.Format(time.RFC3339Nano) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func showHistoryLoadMore(data historyRenderData) bool { | |||
| 139 | + | return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) { | |||
| 143 | + | if data.Stats != nil { | |||
| 144 | + | return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays | |||
| 145 | + | } | |||
| 146 | + | if data.Analytics != nil { | |||
| 147 | + | return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays | |||
| 148 | + | } | |||
| 149 | + | entries = int64(countHistoryEntries(data.Entries)) | |||
| 150 | + | visits = sumHistoryVisits(data.Entries) | |||
| 151 | + | uniqueDays = countUniqueHistoryDays(data.Entries) | |||
| 152 | + | ||||
| 153 | + | return entries, visits, uniqueDays | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func historyTimelineEmptyMessage(data historyRenderData) string { | |||
| 157 | + | if data.Loading { | |||
| 158 | + | return "Loading history…" | |||
| 159 | + | } | |||
| 160 | + | if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" { | |||
| 161 | + | return "No history entries match the current filters" | |||
| 162 | + | } | |||
| 163 | + | return "No history entries" | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func lastHistoryDateKey(entries []*entity.HistoryEntry) string { | |||
| 167 | + | for i := len(entries) - 1; i >= 0; i-- { | |||
| 168 | + | if entries[i] == nil { | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local()) | |||
| 172 | + | return dateKey | |||
| 173 | + | } | |||
| 174 | + | return "" | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup { | |||
| 178 | + | groups := make([]historyTimelineGroup, 0) | |||
| 179 | + | indexByDate := map[string]int{} | |||
| 180 | + | now := time.Now().Local() | |||
| 181 | + | for _, entry := range entries { | |||
| 182 | + | if entry == nil { | |||
| 183 | + | continue | |||
| 184 | + | } | |||
| 185 | + | dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now) | |||
| 186 | + | idx, ok := indexByDate[dateKey] | |||
| 187 | + | if !ok { | |||
| 188 | + | idx = len(groups) | |||
| 189 | + | indexByDate[dateKey] = idx | |||
| 190 | + | groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label}) | |||
| 191 | + | } | |||
| 192 | + | groups[idx].Entries = append(groups[idx].Entries, entry) | |||
| 193 | + | } | |||
| 194 | + | return groups | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func historyDateKeyAndLabel(ts, now time.Time) (string, string) { | |||
| 198 | + | if ts.IsZero() { | |||
| 199 | + | return "unknown", "Unknown date" | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | local := ts.Local() | |||
| 203 | + | dateKey := local.Format("2006-01-02") | |||
| 204 | + | today := now.Local() | |||
| 205 | + | todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | |||
| 206 | + | yesterdayStart := todayStart.AddDate(0, 0, -1) | |||
| 207 | + | entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) | |||
| 208 | + | ||||
| 209 | + | switch { | |||
| 210 | + | case entryStart.Equal(todayStart): | |||
| 211 | + | return dateKey, "Today" | |||
| 212 | + | case entryStart.Equal(yesterdayStart): | |||
| 213 | + | return dateKey, "Yesterday" | |||
| 214 | + | default: | |||
| 215 | + | return dateKey, local.Format("Mon Jan 2") | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func countHistoryEntries(entries []*entity.HistoryEntry) int { | |||
| 220 | + | count := 0 | |||
| 221 | + | for _, entry := range entries { | |||
| 222 | + | if entry != nil { | |||
| 223 | + | count++ | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | return count | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func sumHistoryVisits(entries []*entity.HistoryEntry) int64 { | |||
| 230 | + | var visits int64 | |||
| 231 | + | for _, entry := range entries { | |||
| 232 | + | if entry == nil { | |||
| 233 | + | continue | |||
| 234 | + | } | |||
| 235 | + | // Each HistoryEntry represents at least one visit, even when VisitCount | |||
| 236 | + | // is unset or non-positive in older persisted rows. | |||
| 237 | + | if entry.VisitCount <= 0 { | |||
| 238 | + | visits++ | |||
| 239 | + | continue | |||
| 240 | + | } | |||
| 241 | + | visits += entry.VisitCount | |||
| 242 | + | } | |||
| 243 | + | return visits | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||
| 247 | + | seen := map[string]struct{}{} | |||
| 248 | + | for _, entry := range entries { | |||
| 249 | + | if entry == nil || entry.LastVisited.IsZero() { | |||
| 250 | + | continue | |||
| 251 | + | } | |||
| 252 | + | seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{} | |||
| 253 | + | } | |||
| 254 | + | return int64(len(seen)) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | func displayHistoryDomain(raw string) string { | |||
| 258 | + | return browserurl.DisplayDomain(raw) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func historyDomainActionKey(domain *entity.DomainStat) string { | |||
| 262 | + | if domain == nil { | |||
| 263 | + | return "" | |||
| 264 | + | } | |||
| 265 | + | return browserurl.CanonicalDomain(domain.Domain) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func historyDomainDisplayLabel(domain *entity.DomainStat) string { | |||
| 269 | + | if domain == nil { | |||
| 270 | + | return "" | |||
| 271 | + | } | |||
| 272 | + | return displayHistoryDomain(domain.Domain) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func formatHistoryTime(ts time.Time) string { | |||
| 276 | + | if ts.IsZero() { | |||
| 277 | + | return "" | |||
| 278 | + | } | |||
| 279 | + | return ts.Local().Format("15:04") | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func historyItemLabel(entry *entity.HistoryEntry) string { | |||
| 283 | + | if entry == nil { | |||
| 284 | + | return "" | |||
| 285 | + | } | |||
| 286 | + | label := strings.TrimSpace(entry.Title) | |||
| 287 | + | if label == "" { | |||
| 288 | + | label = displayHistoryDomain(entry.URL) | |||
| 289 | + | } | |||
| 290 | + | if label == "" { | |||
| 291 | + | label = entry.URL | |||
| 292 | + | } | |||
| 293 | + | return label | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func historyItemURL(entry *entity.HistoryEntry) string { | |||
| 297 | + | if entry == nil { | |||
| 298 | + | return "" | |||
| 299 | + | } | |||
| 300 | + | return truncateHistoryURL(entry.URL) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func historyItemFaviconURL(entry *entity.HistoryEntry) string { | |||
| 304 | + | if entry == nil { | |||
| 305 | + | return "" | |||
| 306 | + | } | |||
| 307 | + | domain := browserurl.CanonicalDomain(entry.URL) | |||
| 308 | + | if domain == "" { | |||
| 309 | + | return "" | |||
| 310 | + | } | |||
| 311 | + | query := url.Values{} | |||
| 312 | + | query.Set("domain", domain) | |||
| 313 | + | query.Set("size", "32") | |||
| 314 | + | return "/api/favicon?" + query.Encode() | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func historyFaviconClass(faviconURL string) string { | |||
| 318 | + | if strings.TrimSpace(faviconURL) == "" { | |||
| 319 | + | return "sv-history-favicon" | |||
| 320 | + | } | |||
| 321 | + | return "sv-history-favicon sv-history-favicon-has-image" | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func historyFaviconFallback(entry *entity.HistoryEntry) string { | |||
| 325 | + | label := "" | |||
| 326 | + | if entry != nil { | |||
| 327 | + | label = displayHistoryDomain(entry.URL) | |||
| 328 | + | } | |||
| 329 | + | if label == "" { | |||
| 330 | + | return "•" | |||
| 331 | + | } | |||
| 332 | + | for _, r := range label { | |||
| 333 | + | return strings.ToUpper(string(r)) | |||
| 334 | + | } | |||
| 335 | + | return "•" | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func truncateHistoryURL(raw string) string { | |||
| 339 | + | raw = strings.TrimSpace(raw) | |||
| 340 | + | if raw == "" { | |||
| 341 | + | return "" | |||
| 342 | + | } | |||
| 343 | + | runes := []rune(raw) | |||
| 344 | + | if len(runes) <= historyURLDisplayMaxRunes { | |||
| 345 | + | return raw | |||
| 346 | + | } | |||
| 347 | + | return string(runes[:historyURLDisplayMaxRunes-1]) + "…" | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | func historyItemMeta(entry *entity.HistoryEntry) string { | |||
| 351 | + | if entry == nil { | |||
| 352 | + | return "" | |||
| 353 | + | } | |||
| 354 | + | meta := []string{displayHistoryDomain(entry.URL)} | |||
| 355 | + | if entry.VisitCount > 1 { | |||
| 356 | + | meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount)) | |||
| 357 | + | } | |||
| 358 | + | if !entry.LastVisited.IsZero() { | |||
| 359 | + | meta = append(meta, formatHistoryTime(entry.LastVisited)) | |||
| 360 | + | } | |||
| 361 | + | return strings.Join(nonEmptyStrings(meta), " · ") | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func nonEmptyStrings(values []string) []string { | |||
| 365 | + | out := make([]string, 0, len(values)) | |||
| 366 | + | for _, value := range values { | |||
| 367 | + | if strings.TrimSpace(value) != "" { | |||
| 368 | + | out = append(out, value) | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | return out | |||
| 372 | + | } | |||
A internal/ui/systemviews/history_view_test.go +115 -0
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) { | |||
| 13 | + | t.Parallel() | |||
| 14 | + | ||||
| 15 | + | loc := time.FixedZone("test", 2*60*60) | |||
| 16 | + | now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc) | |||
| 17 | + | ||||
| 18 | + | todayKey, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now) | |||
| 19 | + | yesterdayKey, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now) | |||
| 20 | + | olderKey, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now) | |||
| 21 | + | ||||
| 22 | + | assert.Equal(t, "2026-04-24", todayKey) | |||
| 23 | + | assert.Equal(t, "2026-04-23", yesterdayKey) | |||
| 24 | + | assert.Equal(t, "2026-04-20", olderKey) | |||
| 25 | + | assert.Equal(t, "Today", todayLabel) | |||
| 26 | + | assert.Equal(t, "Yesterday", yesterdayLabel) | |||
| 27 | + | assert.Equal(t, "Mon Apr 20", olderLabel) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestHistorySummaryValuesUsesAllProvidedEntries(t *testing.T) { | |||
| 31 | + | t.Parallel() | |||
| 32 | + | ||||
| 33 | + | loc := time.FixedZone("test", 2*60*60) | |||
| 34 | + | data := historyRenderData{Entries: []*entity.HistoryEntry{ | |||
| 35 | + | {VisitCount: 2, LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)}, | |||
| 36 | + | {VisitCount: 3, LastVisited: time.Date(2026, 4, 24, 8, 0, 0, 0, loc)}, | |||
| 37 | + | }} | |||
| 38 | + | ||||
| 39 | + | entries, visits, days := historySummaryValues(data) | |||
| 40 | + | ||||
| 41 | + | assert.Equal(t, int64(2), entries) | |||
| 42 | + | assert.Equal(t, int64(5), visits) | |||
| 43 | + | assert.Equal(t, int64(2), days) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestHistoryShowingLabelDescribesUnpaginatedDefault(t *testing.T) { | |||
| 47 | + | t.Parallel() | |||
| 48 | + | ||||
| 49 | + | data := historyRenderData{Entries: []*entity.HistoryEntry{{}, {}}, Limit: 0} | |||
| 50 | + | ||||
| 51 | + | assert.Equal(t, "Loaded 2 items", historyShowingLabel(data)) | |||
| 52 | + | assert.True(t, disableHistoryNext(data)) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestHistoryTimelineAppendMergesDuplicateFirstDayGroup(t *testing.T) { | |||
| 56 | + | t.Parallel() | |||
| 57 | + | ||||
| 58 | + | loc := time.FixedZone("test", 2*60*60) | |||
| 59 | + | data := historyRenderData{ | |||
| 60 | + | Entries: []*entity.HistoryEntry{ | |||
| 61 | + | {URL: "https://same.example", Title: "Same day", LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)}, | |||
| 62 | + | {URL: "https://older.example", Title: "Older day", LastVisited: time.Date(2026, 4, 22, 8, 0, 0, 0, loc)}, | |||
| 63 | + | }, | |||
| 64 | + | AppendSkipFirstDate: "2026-04-23", | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | html := historyTimelineAppendHTML(data) | |||
| 68 | + | ||||
| 69 | + | assert.NotContains(t, html, ">Thu Apr 23</h3>") | |||
| 70 | + | assert.NotContains(t, html, `<section class="sv-timeline-group"><ul class="sv-list">`) | |||
| 71 | + | assert.Contains(t, html, `data-sv-history-merge-date="2026-04-23"`) | |||
| 72 | + | assert.Contains(t, html, "Same day") | |||
| 73 | + | assert.Contains(t, html, `data-sv-history-date="2026-04-22"`) | |||
| 74 | + | assert.Contains(t, html, ">Wed Apr 22</h3>") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestHistoryItemURLTruncatesLongURLs(t *testing.T) { | |||
| 78 | + | t.Parallel() | |||
| 79 | + | ||||
| 80 | + | longURL := "https://example.com/" + strings.Repeat("a", historyURLDisplayMaxRunes) | |||
| 81 | + | entry := &entity.HistoryEntry{URL: longURL} | |||
| 82 | + | ||||
| 83 | + | displayURL := historyItemURL(entry) | |||
| 84 | + | ||||
| 85 | + | assert.LessOrEqual(t, len([]rune(displayURL)), historyURLDisplayMaxRunes) | |||
| 86 | + | assert.Contains(t, displayURL, "…") | |||
| 87 | + | assert.NotContains(t, displayURL, strings.Repeat("a", historyURLDisplayMaxRunes)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestHistoryItemFaviconURLUsesLocalCachedFaviconAPI(t *testing.T) { | |||
| 91 | + | t.Parallel() | |||
| 92 | + | ||||
| 93 | + | entry := &entity.HistoryEntry{URL: "https://example.com/docs?token=secret#section"} | |||
| 94 | + | ||||
| 95 | + | assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry)) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestHistoryItemFaviconURLKeepsPortsForCacheKeys(t *testing.T) { | |||
| 99 | + | t.Parallel() | |||
| 100 | + | ||||
| 101 | + | entry := &entity.HistoryEntry{URL: "http://localhost:1455/success?token=secret"} | |||
| 102 | + | ||||
| 103 | + | assert.Equal(t, "/api/favicon?domain=localhost%3A1455&size=32", historyItemFaviconURL(entry)) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestHistoryItemFaviconURLIgnoresStoredRemoteFaviconURL(t *testing.T) { | |||
| 107 | + | t.Parallel() | |||
| 108 | + | ||||
| 109 | + | entry := &entity.HistoryEntry{ | |||
| 110 | + | URL: "https://example.com/docs", | |||
| 111 | + | FaviconURL: "https://tracker.example/favicon.png", | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry)) | |||
| 115 | + | } | |||
A internal/ui/systemviews/href.go +44 -0
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const httpsScheme = "https" | |||
| 9 | + | ||||
| 10 | + | func sanitizeHref(raw string) string { | |||
| 11 | + | trimmed := strings.TrimSpace(raw) | |||
| 12 | + | if trimmed == "" || strings.HasPrefix(trimmed, "//") { | |||
| 13 | + | return "#" | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | parsed, err := url.Parse(trimmed) | |||
| 17 | + | if err != nil { | |||
| 18 | + | return "#" | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | if parsed.Scheme == "" { | |||
| 22 | + | if parsed.Host != "" { | |||
| 23 | + | return "#" | |||
| 24 | + | } | |||
| 25 | + | return trimmed | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 29 | + | case httpScheme, httpsScheme: | |||
| 30 | + | if parsed.Host == "" { | |||
| 31 | + | return "#" | |||
| 32 | + | } | |||
| 33 | + | parsed.Scheme = strings.ToLower(parsed.Scheme) | |||
| 34 | + | parsed.User = nil | |||
| 35 | + | return parsed.String() | |||
| 36 | + | case "dumb": | |||
| 37 | + | if parsed.Host == "" && parsed.Opaque == "" { | |||
| 38 | + | return "#" | |||
| 39 | + | } | |||
| 40 | + | return trimmed | |||
| 41 | + | default: | |||
| 42 | + | return "#" | |||
| 43 | + | } | |||
| 44 | + | } | |||
A internal/ui/systemviews/href_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 12 | + | t.Parallel() | |||
| 13 | + | ||||
| 14 | + | html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{ | |||
| 15 | + | {URL: "javascript:alert(1)", Title: "Bad"}, | |||
| 16 | + | {URL: "https://example.com", Title: "Good"}, | |||
| 17 | + | }}) | |||
| 18 | + | ||||
| 19 | + | require.NotContains(t, html, `href="javascript:alert(1)"`) | |||
| 20 | + | assert.Contains(t, html, `href="#"`) | |||
| 21 | + | assert.Contains(t, html, `href="https://example.com"`) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 25 | + | t.Parallel() | |||
| 26 | + | ||||
| 27 | + | html := favoritesHTML(favoritesRenderData{Favorites: []*entity.Favorite{ | |||
| 28 | + | {URL: "javascript:alert(1)", Title: "Bad"}, | |||
| 29 | + | {URL: "https://example.com", Title: "Good"}, | |||
| 30 | + | }}) | |||
| 31 | + | ||||
| 32 | + | require.NotContains(t, html, `href="javascript:alert(1)"`) | |||
| 33 | + | assert.Contains(t, html, `href="#"`) | |||
| 34 | + | assert.Contains(t, html, `href="https://example.com"`) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) { | |||
| 38 | + | t.Parallel() | |||
| 39 | + | ||||
| 40 | + | assert.Equal(t, "#", sanitizeHref("///evil.com")) | |||
| 41 | + | assert.Equal(t, "#", sanitizeHref("//evil.com")) | |||
| 42 | + | assert.Equal(t, "#", sanitizeHref("https:///evil.com")) | |||
| 43 | + | assert.Equal(t, "#", sanitizeHref("http:evil.com")) | |||
| 44 | + | assert.Equal(t, "https://example.com", sanitizeHref("https://example.com")) | |||
| 45 | + | assert.Equal(t, "https://example.com/path", sanitizeHref("https://user:pass@example.com/path")) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) { | |||
| 49 | + | t.Parallel() | |||
| 50 | + | ||||
| 51 | + | assert.Equal(t, "dumb://history", sanitizeHref("dumb://history")) | |||
| 52 | + | assert.Equal(t, "dumb:history", sanitizeHref("dumb:history")) | |||
| 53 | + | assert.Equal(t, "#", sanitizeHref("dumb:///history")) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestFavoritesHTMLSanitizesTagColors(t *testing.T) { | |||
| 57 | + | t.Parallel() | |||
| 58 | + | ||||
| 59 | + | html := favoritesHTML(favoritesRenderData{ | |||
| 60 | + | Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}}, | |||
| 61 | + | Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}}, | |||
| 62 | + | }) | |||
| 63 | + | ||||
| 64 | + | require.NotContains(t, html, `style="background:red`) | |||
| 65 | + | assert.Contains(t, html, "background:#808080") | |||
| 66 | + | } | |||
A internal/ui/systemviews/icons.go +26 -0
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | type systemIcon string | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | iconTrash systemIcon = "trash-2" | |||
| 7 | + | iconX systemIcon = "x" | |||
| 8 | + | iconSearch systemIcon = "search" | |||
| 9 | + | iconSave systemIcon = "save" | |||
| 10 | + | iconPlus systemIcon = "plus" | |||
| 11 | + | iconExternal systemIcon = "external-link" | |||
| 12 | + | iconRotateCCW systemIcon = "rotate-ccw" | |||
| 13 | + | iconCheck systemIcon = "check" | |||
| 14 | + | iconHistory systemIcon = "history" | |||
| 15 | + | iconFolder systemIcon = "folder" | |||
| 16 | + | iconTag systemIcon = "tag" | |||
| 17 | + | iconSettings systemIcon = "settings" | |||
| 18 | + | iconAlert systemIcon = "alert-triangle" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func iconClass(name systemIcon) string { | |||
| 22 | + | if name == "" { | |||
| 23 | + | return "sv-icon" | |||
| 24 | + | } | |||
| 25 | + | return "sv-icon sv-icon-" + string(name) | |||
| 26 | + | } | |||
A internal/ui/systemviews/icons.templ +65 -0
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | templ Icon(name systemIcon) { | |||
| 4 | + | <svg class={ iconClass(name) } xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"> | |||
| 5 | + | if name == iconTrash { | |||
| 6 | + | <path d="M10 11v6"></path> | |||
| 7 | + | <path d="M14 11v6"></path> | |||
| 8 | + | <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path> | |||
| 9 | + | <path d="M3 6h18"></path> | |||
| 10 | + | <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> | |||
| 11 | + | } else if name == iconX { | |||
| 12 | + | <path d="M18 6 6 18"></path> | |||
| 13 | + | <path d="m6 6 12 12"></path> | |||
| 14 | + | } else if name == iconSearch { | |||
| 15 | + | <path d="m21 21-4.34-4.34"></path> | |||
| 16 | + | <circle cx="11" cy="11" r="8"></circle> | |||
| 17 | + | } else if name == iconSave { | |||
| 18 | + | <path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path> | |||
| 19 | + | <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"></path> | |||
| 20 | + | <path d="M7 3v4a1 1 0 0 0 1 1h7"></path> | |||
| 21 | + | } else if name == iconPlus { | |||
| 22 | + | <path d="M5 12h14"></path> | |||
| 23 | + | <path d="M12 5v14"></path> | |||
| 24 | + | } else if name == iconExternal { | |||
| 25 | + | <path d="M15 3h6v6"></path> | |||
| 26 | + | <path d="M10 14 21 3"></path> | |||
| 27 | + | <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> | |||
| 28 | + | } else if name == iconRotateCCW { | |||
| 29 | + | <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> | |||
| 30 | + | <path d="M3 3v5h5"></path> | |||
| 31 | + | } else if name == iconCheck { | |||
| 32 | + | <path d="M20 6 9 17l-5-5"></path> | |||
| 33 | + | } else if name == iconHistory { | |||
| 34 | + | <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> | |||
| 35 | + | <path d="M3 3v5h5"></path> | |||
| 36 | + | <path d="M12 7v5l4 2"></path> | |||
| 37 | + | } else if name == iconFolder { | |||
| 38 | + | <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path> | |||
| 39 | + | } else if name == iconTag { | |||
| 40 | + | <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path> | |||
| 41 | + | <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle> | |||
| 42 | + | } else if name == iconSettings { | |||
| 43 | + | <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"></path> | |||
| 44 | + | <circle cx="12" cy="12" r="3"></circle> | |||
| 45 | + | } else if name == iconAlert { | |||
| 46 | + | <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path> | |||
| 47 | + | <path d="M12 9v4"></path> | |||
| 48 | + | <path d="M12 17h.01"></path> | |||
| 49 | + | } else { | |||
| 50 | + | <circle cx="12" cy="12" r="10"></circle> | |||
| 51 | + | <path d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4"></path> | |||
| 52 | + | <path d="M12 17h.01"></path> | |||
| 53 | + | } | |||
| 54 | + | </svg> | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | templ ButtonIconLabel(name systemIcon, label string) { | |||
| 58 | + | @Icon(name) | |||
| 59 | + | <span>{ label }</span> | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | templ IconOnly(name systemIcon, label string) { | |||
| 63 | + | @Icon(name) | |||
| 64 | + | <span class="sv-sr-only">{ label }</span> | |||
| 65 | + | } | |||
A internal/ui/systemviews/icons_templ.go +224 -0 generated file: collapsed
A internal/ui/systemviews/prefers_dark_js.go +24 -0
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import "syscall/js" | |||
| 6 | + | ||||
| 7 | + | var currentPrefersDarkImpl = func() bool { | |||
| 8 | + | window := js.Global() | |||
| 9 | + | if !window.Truthy() { | |||
| 10 | + | return false | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | matchMedia := window.Get("matchMedia") | |||
| 14 | + | if !matchMedia.Truthy() { | |||
| 15 | + | return false | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | media := matchMedia.Invoke("(prefers-color-scheme: dark)") | |||
| 19 | + | if !media.Truthy() { | |||
| 20 | + | return false | |||
| 21 | + | } | |||
| 22 | + | matches := media.Get("matches") | |||
| 23 | + | return matches.Truthy() && matches.Bool() | |||
| 24 | + | } | |||
A internal/ui/systemviews/prefers_dark_stub.go +5 -0
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | var currentPrefersDarkImpl = func() bool { return false } | |||
A internal/ui/systemviews/render.go +29 -0
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | ||||
| 7 | + | "github.com/a-h/templ" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func renderComponent(ctx context.Context, component templ.Component) (string, error) { | |||
| 11 | + | var buf bytes.Buffer | |||
| 12 | + | if component == nil { | |||
| 13 | + | return "", nil | |||
| 14 | + | } | |||
| 15 | + | if err := component.Render(ctx, &buf); err != nil { | |||
| 16 | + | return "", err | |||
| 17 | + | } | |||
| 18 | + | return buf.String(), nil | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func mustRenderComponent(component templ.Component) string { | |||
| 22 | + | // Render helpers are pure HTML formatting paths; route/action contexts are | |||
| 23 | + | // handled before rendering, so there is no caller context to preserve here. | |||
| 24 | + | html, err := renderComponent(context.Background(), component) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return errorStateHTML(err.Error()) | |||
| 27 | + | } | |||
| 28 | + | return html | |||
| 29 | + | } | |||
A internal/ui/systemviews/router.go +60 -0
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | type Route string | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | RouteUnknown Route = "unknown" | |||
| 12 | + | RouteHistory Route = "history" | |||
| 13 | + | RouteFavorites Route = "favorites" | |||
| 14 | + | RouteConfig Route = "config" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func ParseRoute(uri string) Route { | |||
| 18 | + | if uri == "" { | |||
| 19 | + | return RouteUnknown | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | u, err := url.Parse(uri) | |||
| 23 | + | if err != nil { | |||
| 24 | + | return RouteUnknown | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | switch { | |||
| 28 | + | case strings.EqualFold(u.Scheme, "dumb"): | |||
| 29 | + | return routeFromPageHost(u.Host, u.Opaque) | |||
| 30 | + | case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"): | |||
| 31 | + | path := strings.Trim(u.Path, "/") | |||
| 32 | + | if path == "" { | |||
| 33 | + | return RouteUnknown | |||
| 34 | + | } | |||
| 35 | + | if head, _, ok := strings.Cut(path, "/"); ok { | |||
| 36 | + | path = head | |||
| 37 | + | } | |||
| 38 | + | return routeFromPageHost(path, "") | |||
| 39 | + | default: | |||
| 40 | + | return RouteUnknown | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func routeFromPageHost(host, opaque string) Route { | |||
| 45 | + | page := strings.TrimSpace(host) | |||
| 46 | + | if page == "" { | |||
| 47 | + | page = strings.TrimSpace(opaque) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | switch strings.ToLower(page) { | |||
| 51 | + | case string(RouteHistory): | |||
| 52 | + | return RouteHistory | |||
| 53 | + | case string(RouteFavorites): | |||
| 54 | + | return RouteFavorites | |||
| 55 | + | case string(RouteConfig): | |||
| 56 | + | return RouteConfig | |||
| 57 | + | default: | |||
| 58 | + | return RouteUnknown | |||
| 59 | + | } | |||
| 60 | + | } | |||
A internal/ui/systemviews/shell.go +138 -0
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "html" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/a-h/templ" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type kvPair struct { | |||
| 12 | + | Label string | |||
| 13 | + | Value string | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // renderedPage holds the data needed by renderAppFrame to produce the shell | |||
| 17 | + | // fragment. Route views populate this; the app frame wraps it. | |||
| 18 | + | type renderedPage struct { | |||
| 19 | + | route Route | |||
| 20 | + | title string | |||
| 21 | + | subtitle string | |||
| 22 | + | subtitleDetail string | |||
| 23 | + | body string | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>, | |||
| 27 | + | // or <body>) that wraps route content in the shared shell. page.body is | |||
| 28 | + | // inserted with templ.Raw, so callers must only pass trusted pre-rendered HTML. | |||
| 29 | + | func renderAppFrame(page renderedPage, theme shellTheme) string { | |||
| 30 | + | return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body))) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func pageDocumentTitle(page renderedPage) string { | |||
| 34 | + | if title := strings.TrimSpace(page.title); title != "" { | |||
| 35 | + | return title | |||
| 36 | + | } | |||
| 37 | + | if subtitle := strings.TrimSpace(page.subtitle); subtitle != "" { | |||
| 38 | + | return subtitle | |||
| 39 | + | } | |||
| 40 | + | return routeSubtitle(page.route) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func appRootClass(theme shellTheme) string { | |||
| 44 | + | themeClass := theme.RootClass | |||
| 45 | + | if themeClass == "" { | |||
| 46 | + | themeClass = "sv-dark" | |||
| 47 | + | } | |||
| 48 | + | return "sv-app " + themeClass | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func sectionClass(className string) string { | |||
| 52 | + | classes := "sv-section" | |||
| 53 | + | if strings.TrimSpace(className) != "" { | |||
| 54 | + | classes += " " + strings.TrimSpace(className) | |||
| 55 | + | } | |||
| 56 | + | return classes | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func alertClass(kind string) string { | |||
| 60 | + | classes := "sv-alert" | |||
| 61 | + | switch kind { | |||
| 62 | + | case "error": | |||
| 63 | + | return classes + " sv-alert-error" | |||
| 64 | + | case "success": | |||
| 65 | + | return classes + " sv-alert-success" | |||
| 66 | + | default: | |||
| 67 | + | return classes | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func alertRole(kind string) string { | |||
| 72 | + | if kind == "error" { | |||
| 73 | + | return "alert" | |||
| 74 | + | } | |||
| 75 | + | return "status" | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func buttonClass(extra string) string { | |||
| 79 | + | extra = strings.TrimSpace(extra) | |||
| 80 | + | if extra == "" { | |||
| 81 | + | return "sv-button" | |||
| 82 | + | } | |||
| 83 | + | return "sv-button " + extra | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func listHTML(rows, emptyMessage string) string { | |||
| 87 | + | if strings.TrimSpace(rows) == "" { | |||
| 88 | + | return emptyStateHTML(emptyMessage) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item. | |||
| 95 | + | func linkHTML(href, label string) string { | |||
| 96 | + | return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label)) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func kvHTML(rows []kvPair) string { | |||
| 100 | + | var items strings.Builder | |||
| 101 | + | for _, row := range rows { | |||
| 102 | + | if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" { | |||
| 103 | + | continue | |||
| 104 | + | } | |||
| 105 | + | items.WriteString(kvRowHTML(row.Label, row.Value)) | |||
| 106 | + | } | |||
| 107 | + | if items.Len() == 0 { | |||
| 108 | + | return emptyStateHTML("No configuration details") | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String()) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func kvRowHTML(label, value string) string { | |||
| 115 | + | return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value)) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // sectionHTML wraps content in a styled section with a heading. inner is | |||
| 119 | + | // passed through as raw HTML, so callers must provide trusted markup. | |||
| 120 | + | func sectionHTML(class, title, inner string) string { | |||
| 121 | + | return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`, | |||
| 122 | + | sectionClass(class), | |||
| 123 | + | html.EscapeString(title), | |||
| 124 | + | inner, | |||
| 125 | + | ) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // emptyStateHTML returns a standard empty-state placeholder. | |||
| 129 | + | func emptyStateHTML(message string) string { | |||
| 130 | + | return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message)) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func errorStateHTML(message string) string { | |||
| 134 | + | return fmt.Sprintf( | |||
| 135 | + | `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`, | |||
| 136 | + | html.EscapeString(message), | |||
| 137 | + | ) | |||
| 138 | + | } | |||
A internal/ui/systemviews/shell_test.go +176 -0
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRenderAppFrame_ReturnsFragment(t *testing.T) { | |||
| 10 | + | t.Parallel() | |||
| 11 | + | ||||
| 12 | + | page := renderedPage{ | |||
| 13 | + | route: RouteHistory, | |||
| 14 | + | subtitle: "History", | |||
| 15 | + | body: `<ul><li>hello</li></ul>`, | |||
| 16 | + | } | |||
| 17 | + | theme := shellTheme{RootClass: "sv-dark"} | |||
| 18 | + | ||||
| 19 | + | out := renderAppFrame(page, theme) | |||
| 20 | + | ||||
| 21 | + | // Must be a fragment — no full document tags. | |||
| 22 | + | assert.NotContains(t, out, "<html") | |||
| 23 | + | assert.NotContains(t, out, "<head") | |||
| 24 | + | assert.NotContains(t, out, "<body") | |||
| 25 | + | ||||
| 26 | + | // Root div carries theme class. | |||
| 27 | + | assert.Contains(t, out, `class="sv-app sv-dark"`) | |||
| 28 | + | ||||
| 29 | + | // Route and document title are on the root element. | |||
| 30 | + | assert.Contains(t, out, `data-route="history"`) | |||
| 31 | + | assert.Contains(t, out, `data-page-title="History"`) | |||
| 32 | + | ||||
| 33 | + | // Shell wrapper present. | |||
| 34 | + | assert.Contains(t, out, `class="sv-shell"`) | |||
| 35 | + | ||||
| 36 | + | // Body content passed through. | |||
| 37 | + | assert.Contains(t, out, `<ul><li>hello</li></ul>`) | |||
| 38 | + | ||||
| 39 | + | // Subtitle rendered. | |||
| 40 | + | assert.Contains(t, out, "History") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestRenderAppFrame_DefaultThemeClass(t *testing.T) { | |||
| 44 | + | t.Parallel() | |||
| 45 | + | ||||
| 46 | + | page := renderedPage{ | |||
| 47 | + | route: RouteFavorites, | |||
| 48 | + | subtitle: "Favorites", | |||
| 49 | + | body: `<p>content</p>`, | |||
| 50 | + | } | |||
| 51 | + | // Empty theme — should still get sv-app with sv-dark default. | |||
| 52 | + | out := renderAppFrame(page, shellTheme{}) | |||
| 53 | + | ||||
| 54 | + | assert.Contains(t, out, `class="sv-app sv-dark"`) | |||
| 55 | + | assert.Contains(t, out, `data-route="favorites"`) | |||
| 56 | + | assert.Contains(t, out, `data-page-title="Favorites"`) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestRenderAppFrame_DocumentTitleUsesExplicitTitle(t *testing.T) { | |||
| 60 | + | t.Parallel() | |||
| 61 | + | ||||
| 62 | + | page := renderedPage{ | |||
| 63 | + | route: RouteHistory, | |||
| 64 | + | title: "History — 12 entries", | |||
| 65 | + | subtitle: "Recent visits", | |||
| 66 | + | body: `<p>content</p>`, | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | out := renderAppFrame(page, shellTheme{}) | |||
| 70 | + | ||||
| 71 | + | assert.Contains(t, out, `data-page-title="History — 12 entries"`) | |||
| 72 | + | assert.Contains(t, out, "Recent visits") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestRenderAppFrame_InlineVarsApplied(t *testing.T) { | |||
| 76 | + | t.Parallel() | |||
| 77 | + | ||||
| 78 | + | page := renderedPage{ | |||
| 79 | + | route: RouteConfig, | |||
| 80 | + | subtitle: "Config", | |||
| 81 | + | body: `<p>settings</p>`, | |||
| 82 | + | } | |||
| 83 | + | theme := shellTheme{ | |||
| 84 | + | RootClass: "sv-light", | |||
| 85 | + | InlineVars: "--sv-background: #fff;", | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | out := renderAppFrame(page, theme) | |||
| 89 | + | ||||
| 90 | + | assert.Contains(t, out, `style="--sv-background: #fff;"`) | |||
| 91 | + | assert.Contains(t, out, `class="sv-app sv-light"`) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestListHTMLWrapsRows(t *testing.T) { | |||
| 95 | + | t.Parallel() | |||
| 96 | + | ||||
| 97 | + | out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here") | |||
| 98 | + | ||||
| 99 | + | assert.Contains(t, out, `class="sv-list"`) | |||
| 100 | + | assert.Contains(t, out, `class="sv-list-row"`) | |||
| 101 | + | assert.Contains(t, out, "entry") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestListHTMLEmptyState(t *testing.T) { | |||
| 105 | + | t.Parallel() | |||
| 106 | + | ||||
| 107 | + | out := listHTML("", "Nothing here") | |||
| 108 | + | ||||
| 109 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 110 | + | assert.Contains(t, out, "Nothing here") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestLinkHTMLSanitizesHref(t *testing.T) { | |||
| 114 | + | t.Parallel() | |||
| 115 | + | ||||
| 116 | + | out := linkHTML("javascript:alert(1)", "Bad") | |||
| 117 | + | ||||
| 118 | + | assert.Contains(t, out, `class="sv-link"`) | |||
| 119 | + | assert.Contains(t, out, `href="#"`) | |||
| 120 | + | assert.Contains(t, out, "Bad") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestKVHTMLRendersPairs(t *testing.T) { | |||
| 124 | + | t.Parallel() | |||
| 125 | + | ||||
| 126 | + | out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}}) | |||
| 127 | + | ||||
| 128 | + | assert.Contains(t, out, `class="sv-kv"`) | |||
| 129 | + | assert.Contains(t, out, "Engine") | |||
| 130 | + | assert.Contains(t, out, "webkit") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestKVHTMLEmptySlice(t *testing.T) { | |||
| 134 | + | t.Parallel() | |||
| 135 | + | ||||
| 136 | + | out := kvHTML(nil) | |||
| 137 | + | ||||
| 138 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 139 | + | assert.Contains(t, out, "No configuration details") | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestKVHTMLAllEmptyRows(t *testing.T) { | |||
| 143 | + | t.Parallel() | |||
| 144 | + | ||||
| 145 | + | out := kvHTML([]kvPair{{}, {Label: " ", Value: ""}}) | |||
| 146 | + | ||||
| 147 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 148 | + | assert.Contains(t, out, "No configuration details") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestErrorStateHTML(t *testing.T) { | |||
| 152 | + | t.Parallel() | |||
| 153 | + | ||||
| 154 | + | out := errorStateHTML("boom") | |||
| 155 | + | ||||
| 156 | + | assert.Contains(t, out, `class="sv-error"`) | |||
| 157 | + | assert.Contains(t, out, `role="alert"`) | |||
| 158 | + | assert.Contains(t, out, "boom") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestSectionHTML(t *testing.T) { | |||
| 162 | + | t.Parallel() | |||
| 163 | + | ||||
| 164 | + | out := sectionHTML("test-section", "Test Title", "<p>inner</p>") | |||
| 165 | + | assert.Contains(t, out, `class="sv-section test-section"`) | |||
| 166 | + | assert.Contains(t, out, "Test Title") | |||
| 167 | + | assert.Contains(t, out, "<p>inner</p>") | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestEmptyStateHTML(t *testing.T) { | |||
| 171 | + | t.Parallel() | |||
| 172 | + | ||||
| 173 | + | out := emptyStateHTML("Nothing here") | |||
| 174 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 175 | + | assert.Contains(t, out, "Nothing here") | |||
| 176 | + | } | |||
A internal/ui/systemviews/theme.go +190 -0
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "unicode" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type shellTheme struct { | |||
| 12 | + | RootClass string | |||
| 13 | + | InlineVars string | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | shellDarkClass = "sv-dark" | |||
| 18 | + | shellLightClass = "sv-light" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | var cssNamedColors = func() map[string]struct{} { | |||
| 22 | + | //nolint:lll // CSS named color list is clearer as a single literal. | |||
| 23 | + | names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`) | |||
| 24 | + | colors := make(map[string]struct{}, len(names)) | |||
| 25 | + | for _, name := range names { | |||
| 26 | + | colors[name] = struct{}{} | |||
| 27 | + | } | |||
| 28 | + | return colors | |||
| 29 | + | }() | |||
| 30 | + | ||||
| 31 | + | func currentPrefersDark() bool { | |||
| 32 | + | return currentPrefersDarkImpl() | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme { | |||
| 36 | + | if isZeroAppearance(appearance) { | |||
| 37 | + | return shellTheme{} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | var palette dto.ColorPalette | |||
| 41 | + | var rootClass string | |||
| 42 | + | ||||
| 43 | + | switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) { | |||
| 44 | + | case "prefer-dark": | |||
| 45 | + | palette = appearance.DarkPalette | |||
| 46 | + | rootClass = shellDarkClass | |||
| 47 | + | case "prefer-light": | |||
| 48 | + | palette = appearance.LightPalette | |||
| 49 | + | rootClass = shellLightClass | |||
| 50 | + | default: | |||
| 51 | + | if currentPrefersDark() { | |||
| 52 | + | palette = appearance.DarkPalette | |||
| 53 | + | rootClass = shellDarkClass | |||
| 54 | + | } else { | |||
| 55 | + | palette = appearance.LightPalette | |||
| 56 | + | rootClass = shellLightClass | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | return shellTheme{ | |||
| 61 | + | RootClass: rootClass, | |||
| 62 | + | InlineVars: buildInlineVars(appearance, palette), | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string { | |||
| 67 | + | parts := make([]string, 0, 11) | |||
| 68 | + | appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) { | |||
| 69 | + | value, ok := sanitize(value) | |||
| 70 | + | if !ok { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | parts = append(parts, fmt.Sprintf("%s: %s;", name, value)) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor) | |||
| 77 | + | appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor) | |||
| 78 | + | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor) | |||
| 79 | + | appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor) | |||
| 80 | + | appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor) | |||
| 81 | + | appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor) | |||
| 82 | + | appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor) | |||
| 83 | + | appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily) | |||
| 84 | + | appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily) | |||
| 85 | + | appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily) | |||
| 86 | + | if appearance.DefaultFontSize > 0 { | |||
| 87 | + | parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | return strings.Join(parts, " ") | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func sanitizeCSSColor(value string) (string, bool) { | |||
| 94 | + | value = strings.TrimSpace(value) | |||
| 95 | + | if value == "" || hasCSSBreakout(value) { | |||
| 96 | + | return "", false | |||
| 97 | + | } | |||
| 98 | + | lower := strings.ToLower(value) | |||
| 99 | + | if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) { | |||
| 100 | + | return value, true | |||
| 101 | + | } | |||
| 102 | + | return "", false | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func sanitizeCSSFontFamily(value string) (string, bool) { | |||
| 106 | + | value = strings.TrimSpace(value) | |||
| 107 | + | if value == "" || hasCSSBreakout(value) { | |||
| 108 | + | return "", false | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | validated := value | |||
| 112 | + | if quote := value[0]; quote == '\'' || quote == '"' { | |||
| 113 | + | if len(value) < 2 || value[len(value)-1] != quote { | |||
| 114 | + | return "", false | |||
| 115 | + | } | |||
| 116 | + | validated = value[1 : len(value)-1] | |||
| 117 | + | if validated == "" || strings.ContainsAny(validated, "'\"") { | |||
| 118 | + | return "", false | |||
| 119 | + | } | |||
| 120 | + | } else if strings.ContainsAny(value, "'\"") { | |||
| 121 | + | return "", false | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | for _, r := range validated { | |||
| 125 | + | switch { | |||
| 126 | + | case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r): | |||
| 127 | + | case strings.ContainsRune(" ,._-", r): | |||
| 128 | + | default: | |||
| 129 | + | return "", false | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return value, true | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func hasCSSBreakout(value string) bool { | |||
| 136 | + | return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func isHexColor(value string) bool { | |||
| 140 | + | if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 { | |||
| 141 | + | return false | |||
| 142 | + | } | |||
| 143 | + | if value[0] != '#' { | |||
| 144 | + | return false | |||
| 145 | + | } | |||
| 146 | + | for _, r := range value[1:] { | |||
| 147 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { | |||
| 148 | + | return false | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | return true | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func isFunctionalColor(value string) bool { | |||
| 155 | + | prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("} | |||
| 156 | + | matched := false | |||
| 157 | + | for _, prefix := range prefixes { | |||
| 158 | + | if strings.HasPrefix(value, prefix) { | |||
| 159 | + | matched = true | |||
| 160 | + | break | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | if !matched || !strings.HasSuffix(value, ")") { | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") { | |||
| 167 | + | if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) { | |||
| 168 | + | return false | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | return true | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func isNamedColor(value string) bool { | |||
| 175 | + | if value == "transparent" || value == "currentcolor" { | |||
| 176 | + | return true | |||
| 177 | + | } | |||
| 178 | + | _, ok := cssNamedColors[value] | |||
| 179 | + | return ok | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool { | |||
| 183 | + | return appearance.ColorScheme == "" && | |||
| 184 | + | appearance.SansFont == "" && | |||
| 185 | + | appearance.SerifFont == "" && | |||
| 186 | + | appearance.MonospaceFont == "" && | |||
| 187 | + | appearance.DefaultFontSize == 0 && | |||
| 188 | + | appearance.LightPalette == (dto.ColorPalette{}) && | |||
| 189 | + | appearance.DarkPalette == (dto.ColorPalette{}) | |||
| 190 | + | } | |||
A internal/ui/systemviews/theme_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | vars := buildInlineVars(dto.WebUIAppearanceConfig{ | |||
| 14 | + | SansFont: "Inter; color:red", | |||
| 15 | + | SerifFont: "Georgia", | |||
| 16 | + | MonospaceFont: "JetBrains Mono", | |||
| 17 | + | LightPalette: dto.ColorPalette{ | |||
| 18 | + | Background: "#ffffff; color:red", | |||
| 19 | + | Surface: "rgb(1 2 3 / 50%)", | |||
| 20 | + | Text: "transparent", | |||
| 21 | + | Accent: "definitelynotacolor", | |||
| 22 | + | Border: "#ABCDEF80", | |||
| 23 | + | }, | |||
| 24 | + | }, dto.ColorPalette{ | |||
| 25 | + | Background: "#ffffff; color:red", | |||
| 26 | + | Surface: "rgb(1 2 3 / 50%)", | |||
| 27 | + | Text: "transparent", | |||
| 28 | + | Accent: "definitelynotacolor", | |||
| 29 | + | Border: "#ABCDEF80", | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | assert.NotContains(t, vars, "color:red") | |||
| 33 | + | assert.NotContains(t, vars, "--sv-background") | |||
| 34 | + | assert.NotContains(t, vars, "--sv-font-sans") | |||
| 35 | + | assert.Contains(t, vars, "--sv-surface: rgb(1 2 3 / 50%);") | |||
| 36 | + | assert.Contains(t, vars, "--sv-text: transparent;") | |||
| 37 | + | assert.NotContains(t, vars, "definitelynotacolor") | |||
| 38 | + | assert.Contains(t, vars, "--sv-border: #ABCDEF80;") | |||
| 39 | + | assert.Contains(t, vars, "--sv-font-serif: Georgia;") | |||
| 40 | + | assert.Contains(t, vars, "--sv-font-mono: JetBrains Mono;") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestSanitizeCSSFontFamilyQuotes(t *testing.T) { | |||
| 44 | + | t.Parallel() | |||
| 45 | + | ||||
| 46 | + | tests := []struct { | |||
| 47 | + | name string | |||
| 48 | + | value string | |||
| 49 | + | want bool | |||
| 50 | + | }{ | |||
| 51 | + | {name: "unquoted spaces", value: "JetBrains Mono", want: true}, | |||
| 52 | + | {name: "single quoted token", value: "'JetBrains Mono'", want: true}, | |||
| 53 | + | {name: "double quoted token", value: "\"JetBrains Mono\"", want: true}, | |||
| 54 | + | {name: "unquoted single quote", value: "JetBrains' Mono", want: false}, | |||
| 55 | + | {name: "unquoted double quote", value: "JetBrains\" Mono", want: false}, | |||
| 56 | + | {name: "internal quote", value: "'JetBrains' Mono'", want: false}, | |||
| 57 | + | {name: "breakout", value: "'JetBrains Mono'; color:red", want: false}, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | t.Parallel() | |||
| 63 | + | ||||
| 64 | + | _, got := sanitizeCSSFontFamily(tt.value) | |||
| 65 | + | assert.Equal(t, tt.want, got) | |||
| 66 | + | }) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestResolveShellTheme(t *testing.T) { | |||
| 71 | + | orig := currentPrefersDarkImpl | |||
| 72 | + | t.Cleanup(func() { | |||
| 73 | + | currentPrefersDarkImpl = orig | |||
| 74 | + | }) | |||
| 75 | + | ||||
| 76 | + | appearance := dto.WebUIAppearanceConfig{ | |||
| 77 | + | SansFont: "Inter", | |||
| 78 | + | SerifFont: "Georgia", | |||
| 79 | + | MonospaceFont: "JetBrains Mono", | |||
| 80 | + | DefaultFontSize: 16, | |||
| 81 | + | ColorScheme: "default", | |||
| 82 | + | LightPalette: dto.ColorPalette{ | |||
| 83 | + | Background: "#ffffff", | |||
| 84 | + | Surface: "#f8f8f8", | |||
| 85 | + | SurfaceVariant: "#eeeeee", | |||
| 86 | + | Text: "#111111", | |||
| 87 | + | Muted: "#666666", | |||
| 88 | + | Accent: "#0055ff", | |||
| 89 | + | Border: "#dddddd", | |||
| 90 | + | }, | |||
| 91 | + | DarkPalette: dto.ColorPalette{ | |||
| 92 | + | Background: "#111111", | |||
| 93 | + | Surface: "#1a1a1a", | |||
| 94 | + | SurfaceVariant: "#2a2a2a", | |||
| 95 | + | Text: "#f5f5f5", | |||
| 96 | + | Muted: "#a0a0a0", | |||
| 97 | + | Accent: "#66aaff", | |||
| 98 | + | Border: "#333333", | |||
| 99 | + | }, | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | tests := []struct { | |||
| 103 | + | name string | |||
| 104 | + | colorScheme string | |||
| 105 | + | prefersDark bool | |||
| 106 | + | wantClass string | |||
| 107 | + | wantBackground string | |||
| 108 | + | wantSurfaceVariant string | |||
| 109 | + | }{ | |||
| 110 | + | {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"}, | |||
| 111 | + | {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"}, | |||
| 112 | + | {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"}, | |||
| 113 | + | {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"}, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | for _, tt := range tests { | |||
| 117 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 118 | + | currentPrefersDarkImpl = func() bool { return tt.prefersDark } | |||
| 119 | + | ttAppearance := appearance | |||
| 120 | + | ttAppearance.ColorScheme = tt.colorScheme | |||
| 121 | + | ||||
| 122 | + | got := resolveShellTheme(ttAppearance) | |||
| 123 | + | ||||
| 124 | + | assert.Equal(t, tt.wantClass, got.RootClass) | |||
| 125 | + | assert.Contains(t, got.InlineVars, "--sv-background") | |||
| 126 | + | assert.Contains(t, got.InlineVars, "--sv-font-sans") | |||
| 127 | + | assert.Contains(t, got.InlineVars, tt.wantBackground) | |||
| 128 | + | assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant) | |||
| 129 | + | }) | |||
| 130 | + | } | |||
| 131 | + | } | |||
D webui/components.json +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | { | |||
| 2 | - | "$schema": "https://shadcn-svelte.com/schema.json", | |||
| 3 | - | "tailwind": { | |||
| 4 | - | "css": "src/styles/app.css", | |||
| 5 | - | "baseColor": "neutral" | |||
| 6 | - | }, | |||
| 7 | - | "aliases": { | |||
| 8 | - | "components": "$lib/components", | |||
| 9 | - | "utils": "$lib/utils", | |||
| 10 | - | "ui": "$lib/components/ui", | |||
| 11 | - | "hooks": "$lib/hooks", | |||
| 12 | - | "lib": "$lib" | |||
| 13 | - | }, | |||
| 14 | - | "typescript": true, | |||
| 15 | - | "registry": "https://shadcn-svelte.com/registry" | |||
| 16 | - | } | |||
D webui/eslint.config.js +0 -59
| @@ -1,59 +0,0 @@ | |||||
| 1 | - | import js from "@eslint/js"; | |||
| 2 | - | import svelte from "eslint-plugin-svelte"; | |||
| 3 | - | import globals from "globals"; | |||
| 4 | - | import tseslint from "typescript-eslint"; | |||
| 5 | - | import svelteConfig from "./svelte.config.js"; | |||
| 6 | - | ||||
| 7 | - | export default tseslint.config( | |||
| 8 | - | js.configs.recommended, | |||
| 9 | - | ...tseslint.configs.recommended, | |||
| 10 | - | ...svelte.configs.recommended, | |||
| 11 | - | { | |||
| 12 | - | languageOptions: { | |||
| 13 | - | globals: { | |||
| 14 | - | ...globals.browser, | |||
| 15 | - | }, | |||
| 16 | - | }, | |||
| 17 | - | }, | |||
| 18 | - | { | |||
| 19 | - | files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], | |||
| 20 | - | languageOptions: { | |||
| 21 | - | parserOptions: { | |||
| 22 | - | extraFileExtensions: [".svelte"], | |||
| 23 | - | parser: tseslint.parser, | |||
| 24 | - | svelteConfig, | |||
| 25 | - | }, | |||
| 26 | - | }, | |||
| 27 | - | }, | |||
| 28 | - | { | |||
| 29 | - | files: ["**/*.{js,mjs,cjs,ts}"], | |||
| 30 | - | languageOptions: { | |||
| 31 | - | ecmaVersion: 2024, | |||
| 32 | - | sourceType: "module", | |||
| 33 | - | }, | |||
| 34 | - | rules: { | |||
| 35 | - | // Modern TypeScript best practices | |||
| 36 | - | "@typescript-eslint/no-unused-vars": [ | |||
| 37 | - | "error", | |||
| 38 | - | { | |||
| 39 | - | argsIgnorePattern: "^_", | |||
| 40 | - | varsIgnorePattern: "^_", | |||
| 41 | - | }, | |||
| 42 | - | ], | |||
| 43 | - | "@typescript-eslint/no-explicit-any": "warn", | |||
| 44 | - | "@typescript-eslint/no-empty-function": "off", | |||
| 45 | - | "@typescript-eslint/consistent-type-imports": "error", | |||
| 46 | - | "@typescript-eslint/no-import-type-side-effects": "error", | |||
| 47 | - | ||||
| 48 | - | // Modern JavaScript/TypeScript patterns | |||
| 49 | - | "prefer-const": "error", | |||
| 50 | - | "no-var": "error", | |||
| 51 | - | "object-shorthand": "error", | |||
| 52 | - | "prefer-template": "error", | |||
| 53 | - | }, | |||
| 54 | - | }, | |||
| 55 | - | { | |||
| 56 | - | files: ["**/*.js", "**/*.mjs"], | |||
| 57 | - | ...tseslint.configs.disableTypeChecked, | |||
| 58 | - | }, | |||
| 59 | - | ); | |||
D webui/package-lock.json +0 -3903 generated file: collapsed
D webui/package.json +0 -52
| @@ -1,52 +0,0 @@ | |||||
| 1 | - | { | |||
| 2 | - | "name": "@dumber/gui", | |||
| 3 | - | "version": "0.1.0", | |||
| 4 | - | "private": true, | |||
| 5 | - | "type": "module", | |||
| 6 | - | "scripts": { | |||
| 7 | - | "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build", | |||
| 8 | - | "build:homepage": "VITE_PAGE=homepage vite build", | |||
| 9 | - | "build:error": "VITE_PAGE=error vite build", | |||
| 10 | - | "build:config": "VITE_PAGE=config vite build", | |||
| 11 | - | "build:webrtc": "VITE_PAGE=webrtc vite build", | |||
| 12 | - | "dev": "VITE_PAGE=homepage vite build --watch", | |||
| 13 | - | "dev:homepage": "VITE_PAGE=homepage vite build --watch", | |||
| 14 | - | "dev:error": "VITE_PAGE=error vite build --watch", | |||
| 15 | - | "dev:config": "VITE_PAGE=config vite build --watch", | |||
| 16 | - | "dev:webrtc": "VITE_PAGE=webrtc vite build --watch", | |||
| 17 | - | "check": "svelte-check", | |||
| 18 | - | "lint": "eslint src --fix", | |||
| 19 | - | "fmt": "prettier --write ." | |||
| 20 | - | }, | |||
| 21 | - | "devDependencies": { | |||
| 22 | - | "@internationalized/date": "^3.12.0", | |||
| 23 | - | "@lucide/svelte": "^0.577.0", | |||
| 24 | - | "@sveltejs/vite-plugin-svelte": "^7.0.0", | |||
| 25 | - | "@tailwindcss/cli": "^4.2.2", | |||
| 26 | - | "@tailwindcss/postcss": "^4.2.2", | |||
| 27 | - | "@types/node": "^25.5.0", | |||
| 28 | - | "@typescript-eslint/eslint-plugin": "^8.57.1", | |||
| 29 | - | "@typescript-eslint/parser": "^8.57.1", | |||
| 30 | - | "autoprefixer": "^10.4.27", | |||
| 31 | - | "eslint": "^10.0.3", | |||
| 32 | - | "eslint-plugin-svelte": "^3.15.2", | |||
| 33 | - | "globals": "^17.4.0", | |||
| 34 | - | "postcss": "^8.5.8", | |||
| 35 | - | "svelte": "^5.54.0", | |||
| 36 | - | "svelte-awesome-color-picker": "^4.1.0", | |||
| 37 | - | "svelte-check": "^4.4.5", | |||
| 38 | - | "tailwind-variants": "^3.2.2", | |||
| 39 | - | "tailwindcss": "^4.0.0", | |||
| 40 | - | "tw-animate-css": "^1.4.0", | |||
| 41 | - | "typescript": "^5.3.0", | |||
| 42 | - | "typescript-eslint": "^8.57.1", | |||
| 43 | - | "vite": "^8.0.0" | |||
| 44 | - | }, | |||
| 45 | - | "dependencies": { | |||
| 46 | - | "bits-ui": "^2.16.3", | |||
| 47 | - | "clsx": "^2.1.1", | |||
| 48 | - | "frappe-charts": "^1.6.2", | |||
| 49 | - | "mode-watcher": "^1.1.0", | |||
| 50 | - | "tailwind-merge": "^3.5.0" | |||
| 51 | - | } | |||
| 52 | - | } | |||
D webui/postcss.config.js +0 -6
| @@ -1,6 +0,0 @@ | |||||
| 1 | - | export default { | |||
| 2 | - | plugins: { | |||
| 3 | - | "@tailwindcss/postcss": {}, | |||
| 4 | - | autoprefixer: {}, | |||
| 5 | - | }, | |||
| 6 | - | }; | |||
D webui/public/favicon.png +0 -0 binary file: no text diff
binary file: no text diff
D webui/src/lib/bridge.ts +0 -165
| @@ -1,165 +0,0 @@ | |||||
| 1 | - | /** | |||
| 2 | - | * WebKit bridge utilities for communicating with the Go backend. | |||
| 3 | - | * | |||
| 4 | - | * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived). | |||
| 5 | - | * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it | |||
| 6 | - | * fails silently, dropping the message entirely. This module provides safe | |||
| 7 | - | * serialization that converts Proxies to plain objects before sending. | |||
| 8 | - | */ | |||
| 9 | - | ||||
| 10 | - | export interface BridgeMessage { | |||
| 11 | - | type: string; | |||
| 12 | - | webviewId: number; | |||
| 13 | - | payload: Record<string, unknown>; | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | interface WebKitBridge { | |||
| 17 | - | postMessage: (msg: unknown) => void; | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | /** | |||
| 21 | - | * Get the native bridge, if available. | |||
| 22 | - | * WebKit exposes `window.webkit.messageHandlers.dumber` while CEF injects | |||
| 23 | - | * `window.dumber.postMessage`. | |||
| 24 | - | */ | |||
| 25 | - | export function getWebKitBridge(): WebKitBridge | null { | |||
| 26 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 27 | - | const bridge = (window as any).webkit?.messageHandlers?.dumber ?? (window as any).dumber; | |||
| 28 | - | return bridge && typeof bridge.postMessage === "function" ? bridge : null; | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | /** | |||
| 32 | - | * Get the current webview ID assigned by the Go backend. | |||
| 33 | - | */ | |||
| 34 | - | export function getWebViewId(): number { | |||
| 35 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 36 | - | return (window as any).__dumber_webview_id || 0; | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | /** | |||
| 40 | - | * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects. | |||
| 41 | - | * Uses JSON serialization which strips Proxy wrappers. | |||
| 42 | - | * | |||
| 43 | - | * @throws Error if the object contains non-serializable values (functions, circular refs, etc.) | |||
| 44 | - | */ | |||
| 45 | - | export function toPlainObject<T>(obj: T): T { | |||
| 46 | - | try { | |||
| 47 | - | return JSON.parse(JSON.stringify(obj)); | |||
| 48 | - | } catch (err) { | |||
| 49 | - | const message = err instanceof Error ? err.message : String(err); | |||
| 50 | - | throw new Error(`Failed to serialize object for bridge: ${message}`); | |||
| 51 | - | } | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | /** | |||
| 55 | - | * Post a message to the Go backend via the native bridge. | |||
| 56 | - | * | |||
| 57 | - | * Automatically: | |||
| 58 | - | * - Converts Svelte 5 Proxy objects to plain objects | |||
| 59 | - | * - Adds webviewId if not provided | |||
| 60 | - | * - Logs errors if serialization fails | |||
| 61 | - | * | |||
| 62 | - | * @throws Error if bridge is not available or serialization fails | |||
| 63 | - | */ | |||
| 64 | - | export function postMessage(msg: BridgeMessage): void { | |||
| 65 | - | const bridge = getWebKitBridge(); | |||
| 66 | - | if (!bridge) { | |||
| 67 | - | throw new Error("Dumber bridge not available"); | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | // Ensure webviewId is set | |||
| 71 | - | if (!msg.webviewId) { | |||
| 72 | - | msg.webviewId = getWebViewId(); | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // Convert to plain object to strip Svelte 5 Proxies | |||
| 76 | - | // This is critical - WebKit silently drops messages with Proxy objects | |||
| 77 | - | const plainMsg = toPlainObject(msg); | |||
| 78 | - | bridge.postMessage(plainMsg); | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | /** | |||
| 82 | - | * Internal callback registry using Map for tracking pending requests. | |||
| 83 | - | * Callbacks must also be exposed on window for Go backend to call, | |||
| 84 | - | * but we track them here for proper cleanup and to prevent leaks. | |||
| 85 | - | */ | |||
| 86 | - | const pendingCallbacks = new Map<string, { | |||
| 87 | - | cleanup: () => void; | |||
| 88 | - | timeoutId: ReturnType<typeof setTimeout>; | |||
| 89 | - | }>(); | |||
| 90 | - | ||||
| 91 | - | let callbackCounter = 0; | |||
| 92 | - | const CALLBACK_TIMEOUT_MS = 30000; | |||
| 93 | - | ||||
| 94 | - | /** | |||
| 95 | - | * Post a message and set up success/error callbacks with automatic cleanup. | |||
| 96 | - | * Uses unique IDs to prevent naming conflicts between concurrent requests. | |||
| 97 | - | * | |||
| 98 | - | * @param type - Message type (e.g., "get_keybindings", "set_keybinding") | |||
| 99 | - | * @param payload - Message payload (will be serialized to strip Proxies) | |||
| 100 | - | * @param successCallback - Base name for success callback (will be made unique) | |||
| 101 | - | * @param errorCallback - Base name for error callback (will be made unique) | |||
| 102 | - | * @param onSuccess - Handler for success response | |||
| 103 | - | * @param onError - Handler for error response | |||
| 104 | - | */ | |||
| 105 | - | export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>( | |||
| 106 | - | type: string, | |||
| 107 | - | payload: TPayload, | |||
| 108 | - | successCallback: string, | |||
| 109 | - | errorCallback: string, | |||
| 110 | - | onSuccess: (response: TResponse) => void, | |||
| 111 | - | onError: (error: string) => void, | |||
| 112 | - | ): void { | |||
| 113 | - | const id = ++callbackCounter; | |||
| 114 | - | const successKey = `${successCallback}_${id}`; | |||
| 115 | - | const errorKey = `${errorCallback}_${id}`; | |||
| 116 | - | ||||
| 117 | - | // Cleanup removes from both window and our tracking Map | |||
| 118 | - | const cleanup = () => { | |||
| 119 | - | const entry = pendingCallbacks.get(successKey); | |||
| 120 | - | if (entry) { | |||
| 121 | - | clearTimeout(entry.timeoutId); | |||
| 122 | - | pendingCallbacks.delete(successKey); | |||
| 123 | - | } | |||
| 124 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 125 | - | delete (window as any)[successKey]; | |||
| 126 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 127 | - | delete (window as any)[errorKey]; | |||
| 128 | - | }; | |||
| 129 | - | ||||
| 130 | - | // Timeout prevents memory leaks if backend never responds | |||
| 131 | - | const timeoutId = setTimeout(() => { | |||
| 132 | - | cleanup(); | |||
| 133 | - | onError("Request timed out"); | |||
| 134 | - | }, CALLBACK_TIMEOUT_MS); | |||
| 135 | - | ||||
| 136 | - | // Track in our Map for proper lifecycle management | |||
| 137 | - | pendingCallbacks.set(successKey, { cleanup, timeoutId }); | |||
| 138 | - | ||||
| 139 | - | // Expose on window for Go backend (it calls window[callbackName](response)) | |||
| 140 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 141 | - | (window as any)[successKey] = (response: TResponse) => { | |||
| 142 | - | cleanup(); | |||
| 143 | - | onSuccess(response); | |||
| 144 | - | }; | |||
| 145 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 146 | - | (window as any)[errorKey] = (msg: unknown) => { | |||
| 147 | - | cleanup(); | |||
| 148 | - | onError(typeof msg === "string" ? msg : "Unknown error"); | |||
| 149 | - | }; | |||
| 150 | - | ||||
| 151 | - | try { | |||
| 152 | - | postMessage({ | |||
| 153 | - | type, | |||
| 154 | - | webviewId: getWebViewId(), | |||
| 155 | - | payload: { | |||
| 156 | - | ...payload, | |||
| 157 | - | successCallback: successKey, | |||
| 158 | - | errorCallback: errorKey, | |||
| 159 | - | }, | |||
| 160 | - | }); | |||
| 161 | - | } catch (err) { | |||
| 162 | - | cleanup(); | |||
| 163 | - | onError(err instanceof Error ? err.message : String(err)); | |||
| 164 | - | } | |||
| 165 | - | } | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +0 -27
| @@ -1,27 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { buttonVariants } from "$lib/components/ui/button/index.js"; | |||
| 4 | - | import { cn } from "$lib/utils.js"; | |||
| 5 | - | import type { Snippet } from "svelte"; | |||
| 6 | - | import type { MouseEventHandler } from "svelte/elements"; | |||
| 7 | - | ||||
| 8 | - | let { | |||
| 9 | - | ref = $bindable(null), | |||
| 10 | - | class: className, | |||
| 11 | - | children, | |||
| 12 | - | onclick, | |||
| 13 | - | ...restProps | |||
| 14 | - | }: AlertDialogPrimitive.ActionProps & { | |||
| 15 | - | children?: Snippet; | |||
| 16 | - | onclick?: MouseEventHandler<HTMLButtonElement>; | |||
| 17 | - | } = $props(); | |||
| 18 | - | </script> | |||
| 19 | - | ||||
| 20 | - | <AlertDialogPrimitive.Action | |||
| 21 | - | bind:ref | |||
| 22 | - | class={cn(buttonVariants(), className)} | |||
| 23 | - | {onclick} | |||
| 24 | - | {...restProps} | |||
| 25 | - | > | |||
| 26 | - | {@render children?.()} | |||
| 27 | - | </AlertDialogPrimitive.Action> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +0 -23
| @@ -1,23 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { buttonVariants } from "$lib/components/ui/button/index.js"; | |||
| 4 | - | import { cn } from "$lib/utils.js"; | |||
| 5 | - | import type { Snippet } from "svelte"; | |||
| 6 | - | ||||
| 7 | - | let { | |||
| 8 | - | ref = $bindable(null), | |||
| 9 | - | class: className, | |||
| 10 | - | children, | |||
| 11 | - | ...restProps | |||
| 12 | - | }: AlertDialogPrimitive.CancelProps & { | |||
| 13 | - | children?: Snippet; | |||
| 14 | - | } = $props(); | |||
| 15 | - | </script> | |||
| 16 | - | ||||
| 17 | - | <AlertDialogPrimitive.Cancel | |||
| 18 | - | bind:ref | |||
| 19 | - | class={cn(buttonVariants({ variant: "outline" }), className)} | |||
| 20 | - | {...restProps} | |||
| 21 | - | > | |||
| 22 | - | {@render children?.()} | |||
| 23 | - | </AlertDialogPrimitive.Cancel> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +0 -24
| @@ -1,24 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | import AlertDialogOverlay from "./alert-dialog-overlay.svelte"; | |||
| 5 | - | import AlertDialogPortal from "./alert-dialog-portal.svelte"; | |||
| 6 | - | ||||
| 7 | - | let { | |||
| 8 | - | ref = $bindable(null), | |||
| 9 | - | class: className, | |||
| 10 | - | ...restProps | |||
| 11 | - | }: AlertDialogPrimitive.ContentProps = $props(); | |||
| 12 | - | </script> | |||
| 13 | - | ||||
| 14 | - | <AlertDialogPortal> | |||
| 15 | - | <AlertDialogOverlay /> | |||
| 16 | - | <AlertDialogPrimitive.Content | |||
| 17 | - | bind:ref | |||
| 18 | - | class={cn( | |||
| 19 | - | "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg", | |||
| 20 | - | className | |||
| 21 | - | )} | |||
| 22 | - | {...restProps} | |||
| 23 | - | /> | |||
| 24 | - | </AlertDialogPortal> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: AlertDialogPrimitive.DescriptionProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <AlertDialogPrimitive.Description | |||
| 13 | - | bind:ref | |||
| 14 | - | class={cn("text-sm text-muted-foreground", className)} | |||
| 15 | - | {...restProps} | |||
| 16 | - | /> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +0 -19
| @@ -1,19 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} | |||
| 16 | - | {...restProps} | |||
| 17 | - | > | |||
| 18 | - | {@render children?.()} | |||
| 19 | - | </div> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +0 -19
| @@ -1,19 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | class={cn("flex flex-col gap-2 text-center sm:text-left", className)} | |||
| 16 | - | {...restProps} | |||
| 17 | - | > | |||
| 18 | - | {@render children?.()} | |||
| 19 | - | </div> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +0 -19
| @@ -1,19 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: AlertDialogPrimitive.OverlayProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <AlertDialogPrimitive.Overlay | |||
| 13 | - | bind:ref | |||
| 14 | - | class={cn( | |||
| 15 | - | "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80", | |||
| 16 | - | className | |||
| 17 | - | )} | |||
| 18 | - | {...restProps} | |||
| 19 | - | /> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +0 -7
| @@ -1,7 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | ||||
| 4 | - | let { ...restProps }: AlertDialogPrimitive.PortalProps = $props(); | |||
| 5 | - | </script> | |||
| 6 | - | ||||
| 7 | - | <AlertDialogPrimitive.Portal {...restProps} /> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: AlertDialogPrimitive.TitleProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <AlertDialogPrimitive.Title | |||
| 13 | - | bind:ref | |||
| 14 | - | class={cn("text-lg font-semibold", className)} | |||
| 15 | - | {...restProps} | |||
| 16 | - | /> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: AlertDialogPrimitive.TriggerProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <AlertDialogPrimitive.Trigger | |||
| 13 | - | bind:ref | |||
| 14 | - | class={cn(className)} | |||
| 15 | - | {...restProps} | |||
| 16 | - | /> | |||
D webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +0 -10
| @@ -1,10 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; | |||
| 3 | - | ||||
| 4 | - | let { | |||
| 5 | - | open = $bindable(false), | |||
| 6 | - | ...restProps | |||
| 7 | - | }: AlertDialogPrimitive.RootProps = $props(); | |||
| 8 | - | </script> | |||
| 9 | - | ||||
| 10 | - | <AlertDialogPrimitive.Root bind:open {...restProps} /> | |||
D webui/src/lib/components/ui/alert-dialog/index.ts +0 -37
| @@ -1,37 +0,0 @@ | |||||
| 1 | - | import Root from "./alert-dialog.svelte"; | |||
| 2 | - | import Trigger from "./alert-dialog-trigger.svelte"; | |||
| 3 | - | import Portal from "./alert-dialog-portal.svelte"; | |||
| 4 | - | import Overlay from "./alert-dialog-overlay.svelte"; | |||
| 5 | - | import Content from "./alert-dialog-content.svelte"; | |||
| 6 | - | import Header from "./alert-dialog-header.svelte"; | |||
| 7 | - | import Footer from "./alert-dialog-footer.svelte"; | |||
| 8 | - | import Title from "./alert-dialog-title.svelte"; | |||
| 9 | - | import Description from "./alert-dialog-description.svelte"; | |||
| 10 | - | import Action from "./alert-dialog-action.svelte"; | |||
| 11 | - | import Cancel from "./alert-dialog-cancel.svelte"; | |||
| 12 | - | ||||
| 13 | - | export { | |||
| 14 | - | Root, | |||
| 15 | - | Trigger, | |||
| 16 | - | Portal, | |||
| 17 | - | Overlay, | |||
| 18 | - | Content, | |||
| 19 | - | Header, | |||
| 20 | - | Footer, | |||
| 21 | - | Title, | |||
| 22 | - | Description, | |||
| 23 | - | Action, | |||
| 24 | - | Cancel, | |||
| 25 | - | // | |||
| 26 | - | Root as AlertDialog, | |||
| 27 | - | Trigger as AlertDialogTrigger, | |||
| 28 | - | Portal as AlertDialogPortal, | |||
| 29 | - | Overlay as AlertDialogOverlay, | |||
| 30 | - | Content as AlertDialogContent, | |||
| 31 | - | Header as AlertDialogHeader, | |||
| 32 | - | Footer as AlertDialogFooter, | |||
| 33 | - | Title as AlertDialogTitle, | |||
| 34 | - | Description as AlertDialogDescription, | |||
| 35 | - | Action as AlertDialogAction, | |||
| 36 | - | Cancel as AlertDialogCancel, | |||
| 37 | - | }; | |||
D webui/src/lib/components/ui/button/button.svelte +0 -82
| @@ -1,82 +0,0 @@ | |||||
| 1 | - | <script lang="ts" module> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements"; | |||
| 4 | - | import { type VariantProps, tv } from "tailwind-variants"; | |||
| 5 | - | ||||
| 6 | - | export const buttonVariants = tv({ | |||
| 7 | - | base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | |||
| 8 | - | variants: { | |||
| 9 | - | variant: { | |||
| 10 | - | default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs", | |||
| 11 | - | destructive: | |||
| 12 | - | "bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs", | |||
| 13 | - | outline: | |||
| 14 | - | "bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border border-border shadow-xs", | |||
| 15 | - | secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs", | |||
| 16 | - | ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", | |||
| 17 | - | link: "text-primary underline-offset-4 hover:underline", | |||
| 18 | - | }, | |||
| 19 | - | size: { | |||
| 20 | - | default: "h-9 px-4 py-2 has-[>svg]:px-3", | |||
| 21 | - | sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5", | |||
| 22 | - | lg: "h-10 px-6 has-[>svg]:px-4", | |||
| 23 | - | icon: "size-9", | |||
| 24 | - | "icon-sm": "size-8", | |||
| 25 | - | "icon-lg": "size-10", | |||
| 26 | - | }, | |||
| 27 | - | }, | |||
| 28 | - | defaultVariants: { | |||
| 29 | - | variant: "default", | |||
| 30 | - | size: "default", | |||
| 31 | - | }, | |||
| 32 | - | }); | |||
| 33 | - | ||||
| 34 | - | export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"]; | |||
| 35 | - | export type ButtonSize = VariantProps<typeof buttonVariants>["size"]; | |||
| 36 | - | ||||
| 37 | - | export type ButtonProps = WithElementRef<HTMLButtonAttributes> & | |||
| 38 | - | WithElementRef<HTMLAnchorAttributes> & { | |||
| 39 | - | variant?: ButtonVariant; | |||
| 40 | - | size?: ButtonSize; | |||
| 41 | - | }; | |||
| 42 | - | </script> | |||
| 43 | - | ||||
| 44 | - | <script lang="ts"> | |||
| 45 | - | let { | |||
| 46 | - | class: className, | |||
| 47 | - | variant = "default", | |||
| 48 | - | size = "default", | |||
| 49 | - | ref = $bindable(null), | |||
| 50 | - | href = undefined, | |||
| 51 | - | type = "button", | |||
| 52 | - | disabled, | |||
| 53 | - | children, | |||
| 54 | - | ...restProps | |||
| 55 | - | }: ButtonProps = $props(); | |||
| 56 | - | </script> | |||
| 57 | - | ||||
| 58 | - | {#if href} | |||
| 59 | - | <a | |||
| 60 | - | bind:this={ref} | |||
| 61 | - | data-slot="button" | |||
| 62 | - | class={cn(buttonVariants({ variant, size }), className)} | |||
| 63 | - | href={disabled ? undefined : href} | |||
| 64 | - | aria-disabled={disabled} | |||
| 65 | - | role={disabled ? "link" : undefined} | |||
| 66 | - | tabindex={disabled ? -1 : undefined} | |||
| 67 | - | {...restProps} | |||
| 68 | - | > | |||
| 69 | - | {@render children?.()} | |||
| 70 | - | </a> | |||
| 71 | - | {:else} | |||
| 72 | - | <button | |||
| 73 | - | bind:this={ref} | |||
| 74 | - | data-slot="button" | |||
| 75 | - | class={cn(buttonVariants({ variant, size }), className)} | |||
| 76 | - | {type} | |||
| 77 | - | {disabled} | |||
| 78 | - | {...restProps} | |||
| 79 | - | > | |||
| 80 | - | {@render children?.()} | |||
| 81 | - | </button> | |||
| 82 | - | {/if} | |||
D webui/src/lib/components/ui/button/index.ts +0 -17
| @@ -1,17 +0,0 @@ | |||||
| 1 | - | import Root, { | |||
| 2 | - | type ButtonProps, | |||
| 3 | - | type ButtonSize, | |||
| 4 | - | type ButtonVariant, | |||
| 5 | - | buttonVariants, | |||
| 6 | - | } from "./button.svelte"; | |||
| 7 | - | ||||
| 8 | - | export { | |||
| 9 | - | Root, | |||
| 10 | - | type ButtonProps as Props, | |||
| 11 | - | // | |||
| 12 | - | Root as Button, | |||
| 13 | - | buttonVariants, | |||
| 14 | - | type ButtonProps, | |||
| 15 | - | type ButtonSize, | |||
| 16 | - | type ButtonVariant, | |||
| 17 | - | }; | |||
D webui/src/lib/components/ui/card/card-action.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="card-action" | |||
| 16 | - | class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </div> | |||
D webui/src/lib/components/ui/card/card-content.svelte +0 -15
| @@ -1,15 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}> | |||
| 14 | - | {@render children?.()} | |||
| 15 | - | </div> | |||
D webui/src/lib/components/ui/card/card-description.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <p | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="card-description" | |||
| 16 | - | class={cn("text-muted-foreground text-sm", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </p> | |||
D webui/src/lib/components/ui/card/card-footer.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="card-footer" | |||
| 16 | - | class={cn("flex items-center px-6 [.border-t]:pt-6", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </div> | |||
D webui/src/lib/components/ui/card/card-header.svelte +0 -23
| @@ -1,23 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="card-header" | |||
| 16 | - | class={cn( | |||
| 17 | - | "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", | |||
| 18 | - | className | |||
| 19 | - | )} | |||
| 20 | - | {...restProps} | |||
| 21 | - | > | |||
| 22 | - | {@render children?.()} | |||
| 23 | - | </div> | |||
D webui/src/lib/components/ui/card/card-title.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="card-title" | |||
| 16 | - | class={cn("leading-none font-semibold", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </div> | |||
D webui/src/lib/components/ui/card/card.svelte +0 -23
| @@ -1,23 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="card" | |||
| 16 | - | class={cn( | |||
| 17 | - | "bg-card text-card-foreground flex flex-col gap-6 border border-border py-6 shadow-sm", | |||
| 18 | - | className | |||
| 19 | - | )} | |||
| 20 | - | {...restProps} | |||
| 21 | - | > | |||
| 22 | - | {@render children?.()} | |||
| 23 | - | </div> | |||
D webui/src/lib/components/ui/card/index.ts +0 -25
| @@ -1,25 +0,0 @@ | |||||
| 1 | - | import Root from "./card.svelte"; | |||
| 2 | - | import Content from "./card-content.svelte"; | |||
| 3 | - | import Description from "./card-description.svelte"; | |||
| 4 | - | import Footer from "./card-footer.svelte"; | |||
| 5 | - | import Header from "./card-header.svelte"; | |||
| 6 | - | import Title from "./card-title.svelte"; | |||
| 7 | - | import Action from "./card-action.svelte"; | |||
| 8 | - | ||||
| 9 | - | export { | |||
| 10 | - | Root, | |||
| 11 | - | Content, | |||
| 12 | - | Description, | |||
| 13 | - | Footer, | |||
| 14 | - | Header, | |||
| 15 | - | Title, | |||
| 16 | - | Action, | |||
| 17 | - | // | |||
| 18 | - | Root as Card, | |||
| 19 | - | Content as CardContent, | |||
| 20 | - | Description as CardDescription, | |||
| 21 | - | Footer as CardFooter, | |||
| 22 | - | Header as CardHeader, | |||
| 23 | - | Title as CardTitle, | |||
| 24 | - | Action as CardAction, | |||
| 25 | - | }; | |||
D webui/src/lib/components/ui/color-picker/color-picker.svelte +0 -80
| @@ -1,80 +0,0 @@ | |||||
| 1 | - | <script lang="ts" module> | |||
| 2 | - | export type ColorPickerProps = { | |||
| 3 | - | value: string; | |||
| 4 | - | onValueChange?: (value: string) => void; | |||
| 5 | - | id?: string; | |||
| 6 | - | class?: string; | |||
| 7 | - | }; | |||
| 8 | - | </script> | |||
| 9 | - | ||||
| 10 | - | <script lang="ts"> | |||
| 11 | - | import ColorPicker from "svelte-awesome-color-picker"; | |||
| 12 | - | import { Popover } from "bits-ui"; | |||
| 13 | - | import { cn } from "$lib/utils.js"; | |||
| 14 | - | ||||
| 15 | - | let { | |||
| 16 | - | class: className, | |||
| 17 | - | value = $bindable("#000000"), | |||
| 18 | - | onValueChange, | |||
| 19 | - | id, | |||
| 20 | - | }: ColorPickerProps = $props(); | |||
| 21 | - | ||||
| 22 | - | let open = $state(false); | |||
| 23 | - | ||||
| 24 | - | function handleInput(event: { hex: string | null }) { | |||
| 25 | - | if (event.hex) { | |||
| 26 | - | value = event.hex; | |||
| 27 | - | onValueChange?.(event.hex); | |||
| 28 | - | } | |||
| 29 | - | } | |||
| 30 | - | </script> | |||
| 31 | - | ||||
| 32 | - | <Popover.Root bind:open> | |||
| 33 | - | <Popover.Trigger | |||
| 34 | - | {id} | |||
| 35 | - | class={cn( | |||
| 36 | - | "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", | |||
| 37 | - | className | |||
| 38 | - | )} | |||
| 39 | - | > | |||
| 40 | - | <span | |||
| 41 | - | class="block h-6 w-8 border border-border/50" | |||
| 42 | - | style="background-color: {value};" | |||
| 43 | - | aria-hidden="true" | |||
| 44 | - | ></span> | |||
| 45 | - | <span class="sr-only">Choose color: {value}</span> | |||
| 46 | - | </Popover.Trigger> | |||
| 47 | - | ||||
| 48 | - | <Popover.Portal> | |||
| 49 | - | <Popover.Content | |||
| 50 | - | class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg" | |||
| 51 | - | sideOffset={4} | |||
| 52 | - | > | |||
| 53 | - | <ColorPicker | |||
| 54 | - | bind:hex={value} | |||
| 55 | - | onInput={handleInput} | |||
| 56 | - | isDialog={false} | |||
| 57 | - | /> | |||
| 58 | - | <!-- Hex value display --> | |||
| 59 | - | <div class="mt-2 flex items-center gap-2"> | |||
| 60 | - | <span | |||
| 61 | - | class="block h-6 w-6 shrink-0 border border-border" | |||
| 62 | - | style="background-color: {value};" | |||
| 63 | - | aria-hidden="true" | |||
| 64 | - | ></span> | |||
| 65 | - | <span class="font-mono text-sm text-foreground">{value}</span> | |||
| 66 | - | </div> | |||
| 67 | - | </Popover.Content> | |||
| 68 | - | </Popover.Portal> | |||
| 69 | - | </Popover.Root> | |||
| 70 | - | ||||
| 71 | - | <style> | |||
| 72 | - | /* Override svelte-awesome-color-picker styles to match our theme */ | |||
| 73 | - | :global(.color-picker-popover) { | |||
| 74 | - | --cp-bg-color: var(--background); | |||
| 75 | - | --cp-border-color: var(--border); | |||
| 76 | - | --cp-text-color: var(--foreground); | |||
| 77 | - | --cp-input-color: var(--background); | |||
| 78 | - | --cp-button-hover-color: var(--accent); | |||
| 79 | - | } | |||
| 80 | - | </style> | |||
D webui/src/lib/components/ui/color-picker/index.ts +0 -8
| @@ -1,8 +0,0 @@ | |||||
| 1 | - | import Root, { type ColorPickerProps } from "./color-picker.svelte"; | |||
| 2 | - | ||||
| 3 | - | export { | |||
| 4 | - | Root, | |||
| 5 | - | Root as ColorPicker, | |||
| 6 | - | type ColorPickerProps, | |||
| 7 | - | type ColorPickerProps as Props, | |||
| 8 | - | }; | |||
D webui/src/lib/components/ui/input/index.ts +0 -7
| @@ -1,7 +0,0 @@ | |||||
| 1 | - | import Root from "./input.svelte"; | |||
| 2 | - | ||||
| 3 | - | export { | |||
| 4 | - | Root, | |||
| 5 | - | // | |||
| 6 | - | Root as Input, | |||
| 7 | - | }; | |||
D webui/src/lib/components/ui/input/input.svelte +0 -52
| @@ -1,52 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | type InputType = Exclude<HTMLInputTypeAttribute, "file">; | |||
| 6 | - | ||||
| 7 | - | type Props = WithElementRef< | |||
| 8 | - | Omit<HTMLInputAttributes, "type"> & | |||
| 9 | - | ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined }) | |||
| 10 | - | >; | |||
| 11 | - | ||||
| 12 | - | let { | |||
| 13 | - | ref = $bindable(null), | |||
| 14 | - | value = $bindable(), | |||
| 15 | - | type, | |||
| 16 | - | files = $bindable(), | |||
| 17 | - | class: className, | |||
| 18 | - | "data-slot": dataSlot = "input", | |||
| 19 | - | ...restProps | |||
| 20 | - | }: Props = $props(); | |||
| 21 | - | </script> | |||
| 22 | - | ||||
| 23 | - | {#if type === "file"} | |||
| 24 | - | <input | |||
| 25 | - | bind:this={ref} | |||
| 26 | - | data-slot={dataSlot} | |||
| 27 | - | class={cn( | |||
| 28 | - | "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-border ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50", | |||
| 29 | - | "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", | |||
| 30 | - | "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", | |||
| 31 | - | className | |||
| 32 | - | )} | |||
| 33 | - | type="file" | |||
| 34 | - | bind:files | |||
| 35 | - | bind:value | |||
| 36 | - | {...restProps} | |||
| 37 | - | /> | |||
| 38 | - | {:else} | |||
| 39 | - | <input | |||
| 40 | - | bind:this={ref} | |||
| 41 | - | data-slot={dataSlot} | |||
| 42 | - | class={cn( | |||
| 43 | - | "border-border bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", | |||
| 44 | - | "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", | |||
| 45 | - | "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", | |||
| 46 | - | className | |||
| 47 | - | )} | |||
| 48 | - | {type} | |||
| 49 | - | bind:value | |||
| 50 | - | {...restProps} | |||
| 51 | - | /> | |||
| 52 | - | {/if} | |||
D webui/src/lib/components/ui/label/index.ts +0 -7
| @@ -1,7 +0,0 @@ | |||||
| 1 | - | import Root from "./label.svelte"; | |||
| 2 | - | ||||
| 3 | - | export { | |||
| 4 | - | Root, | |||
| 5 | - | // | |||
| 6 | - | Root as Label, | |||
| 7 | - | }; | |||
D webui/src/lib/components/ui/label/label.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { Label as LabelPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: LabelPrimitive.RootProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <LabelPrimitive.Root | |||
| 13 | - | bind:ref | |||
| 14 | - | data-slot="label" | |||
| 15 | - | class={cn( | |||
| 16 | - | "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", | |||
| 17 | - | className | |||
| 18 | - | )} | |||
| 19 | - | {...restProps} | |||
| 20 | - | /> | |||
D webui/src/lib/components/ui/skeleton/index.ts +0 -7
| @@ -1,7 +0,0 @@ | |||||
| 1 | - | import Root from "./skeleton.svelte"; | |||
| 2 | - | ||||
| 3 | - | export { | |||
| 4 | - | Root, | |||
| 5 | - | // | |||
| 6 | - | Root as Skeleton, | |||
| 7 | - | }; | |||
D webui/src/lib/components/ui/skeleton/skeleton.svelte +0 -17
| @@ -1,17 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <div | |||
| 13 | - | bind:this={ref} | |||
| 14 | - | data-slot="skeleton" | |||
| 15 | - | class={cn("bg-accent animate-pulse", className)} | |||
| 16 | - | {...restProps} | |||
| 17 | - | ></div> | |||
D webui/src/lib/components/ui/spinner/index.ts +0 -1
| @@ -1,1 +0,0 @@ | |||||
| 1 | - | export { default as Spinner } from "./spinner.svelte"; | |||
D webui/src/lib/components/ui/spinner/spinner.svelte +0 -14
| @@ -1,14 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn } from "$lib/utils.js"; | |||
| 3 | - | import Loader2Icon from "@lucide/svelte/icons/loader-2"; | |||
| 4 | - | import type { ComponentProps } from "svelte"; | |||
| 5 | - | ||||
| 6 | - | let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props(); | |||
| 7 | - | </script> | |||
| 8 | - | ||||
| 9 | - | <Loader2Icon | |||
| 10 | - | role="status" | |||
| 11 | - | aria-label="Loading" | |||
| 12 | - | class={cn("size-4 animate-spin", className)} | |||
| 13 | - | {...restProps} | |||
| 14 | - | /> | |||
D webui/src/lib/components/ui/table/index.ts +0 -28
| @@ -1,28 +0,0 @@ | |||||
| 1 | - | import Root from "./table.svelte"; | |||
| 2 | - | import Body from "./table-body.svelte"; | |||
| 3 | - | import Caption from "./table-caption.svelte"; | |||
| 4 | - | import Cell from "./table-cell.svelte"; | |||
| 5 | - | import Footer from "./table-footer.svelte"; | |||
| 6 | - | import Head from "./table-head.svelte"; | |||
| 7 | - | import Header from "./table-header.svelte"; | |||
| 8 | - | import Row from "./table-row.svelte"; | |||
| 9 | - | ||||
| 10 | - | export { | |||
| 11 | - | Root, | |||
| 12 | - | Body, | |||
| 13 | - | Caption, | |||
| 14 | - | Cell, | |||
| 15 | - | Footer, | |||
| 16 | - | Head, | |||
| 17 | - | Header, | |||
| 18 | - | Row, | |||
| 19 | - | // | |||
| 20 | - | Root as Table, | |||
| 21 | - | Body as TableBody, | |||
| 22 | - | Caption as TableCaption, | |||
| 23 | - | Cell as TableCell, | |||
| 24 | - | Footer as TableFooter, | |||
| 25 | - | Head as TableHead, | |||
| 26 | - | Header as TableHeader, | |||
| 27 | - | Row as TableRow, | |||
| 28 | - | }; | |||
D webui/src/lib/components/ui/table/table-body.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <tbody | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-body" | |||
| 16 | - | class={cn("[&_tr:last-child]:border-0", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </tbody> | |||
D webui/src/lib/components/ui/table/table-caption.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <caption | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-caption" | |||
| 16 | - | class={cn("text-muted-foreground mt-4 text-sm", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </caption> | |||
D webui/src/lib/components/ui/table/table-cell.svelte +0 -23
| @@ -1,23 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLTdAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLTdAttributes> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <td | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-cell" | |||
| 16 | - | class={cn( | |||
| 17 | - | "bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0", | |||
| 18 | - | className | |||
| 19 | - | )} | |||
| 20 | - | {...restProps} | |||
| 21 | - | > | |||
| 22 | - | {@render children?.()} | |||
| 23 | - | </td> | |||
D webui/src/lib/components/ui/table/table-footer.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <tfoot | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-footer" | |||
| 16 | - | class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </tfoot> | |||
D webui/src/lib/components/ui/table/table-head.svelte +0 -23
| @@ -1,23 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLThAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLThAttributes> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <th | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-head" | |||
| 16 | - | class={cn( | |||
| 17 | - | "text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0", | |||
| 18 | - | className | |||
| 19 | - | )} | |||
| 20 | - | {...restProps} | |||
| 21 | - | > | |||
| 22 | - | {@render children?.()} | |||
| 23 | - | </th> | |||
D webui/src/lib/components/ui/table/table-header.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <thead | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-header" | |||
| 16 | - | class={cn("[&_tr]:border-b", className)} | |||
| 17 | - | {...restProps} | |||
| 18 | - | > | |||
| 19 | - | {@render children?.()} | |||
| 20 | - | </thead> | |||
D webui/src/lib/components/ui/table/table-row.svelte +0 -23
| @@ -1,23 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 3 | - | import type { HTMLAttributes } from "svelte/elements"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <tr | |||
| 14 | - | bind:this={ref} | |||
| 15 | - | data-slot="table-row" | |||
| 16 | - | class={cn( | |||
| 17 | - | "hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", | |||
| 18 | - | className | |||
| 19 | - | )} | |||
| 20 | - | {...restProps} | |||
| 21 | - | > | |||
| 22 | - | {@render children?.()} | |||
| 23 | - | </tr> | |||
D webui/src/lib/components/ui/table/table.svelte +0 -22
| @@ -1,22 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import type { HTMLTableAttributes } from "svelte/elements"; | |||
| 3 | - | import { cn, type WithElementRef } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | children, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: WithElementRef<HTMLTableAttributes> = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <div data-slot="table-container" class="relative w-full overflow-x-auto"> | |||
| 14 | - | <table | |||
| 15 | - | bind:this={ref} | |||
| 16 | - | data-slot="table" | |||
| 17 | - | class={cn("w-full caption-bottom text-sm", className)} | |||
| 18 | - | {...restProps} | |||
| 19 | - | > | |||
| 20 | - | {@render children?.()} | |||
| 21 | - | </table> | |||
| 22 | - | </div> | |||
D webui/src/lib/components/ui/tabs/index.ts +0 -16
| @@ -1,16 +0,0 @@ | |||||
| 1 | - | import Root from "./tabs.svelte"; | |||
| 2 | - | import Content from "./tabs-content.svelte"; | |||
| 3 | - | import List from "./tabs-list.svelte"; | |||
| 4 | - | import Trigger from "./tabs-trigger.svelte"; | |||
| 5 | - | ||||
| 6 | - | export { | |||
| 7 | - | Root, | |||
| 8 | - | Content, | |||
| 9 | - | List, | |||
| 10 | - | Trigger, | |||
| 11 | - | // | |||
| 12 | - | Root as Tabs, | |||
| 13 | - | Content as TabsContent, | |||
| 14 | - | List as TabsList, | |||
| 15 | - | Trigger as TabsTrigger, | |||
| 16 | - | }; | |||
D webui/src/lib/components/ui/tabs/tabs-content.svelte +0 -17
| @@ -1,17 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { Tabs as TabsPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: TabsPrimitive.ContentProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <TabsPrimitive.Content | |||
| 13 | - | bind:ref | |||
| 14 | - | data-slot="tabs-content" | |||
| 15 | - | class={cn("flex-1 outline-none", className)} | |||
| 16 | - | {...restProps} | |||
| 17 | - | /> | |||
D webui/src/lib/components/ui/tabs/tabs-list.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { Tabs as TabsPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: TabsPrimitive.ListProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <TabsPrimitive.List | |||
| 13 | - | bind:ref | |||
| 14 | - | data-slot="tabs-list" | |||
| 15 | - | class={cn( | |||
| 16 | - | "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center border border-border p-[3px]", | |||
| 17 | - | className | |||
| 18 | - | )} | |||
| 19 | - | {...restProps} | |||
| 20 | - | /> | |||
D webui/src/lib/components/ui/tabs/tabs-trigger.svelte +0 -20
| @@ -1,20 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { Tabs as TabsPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | class: className, | |||
| 8 | - | ...restProps | |||
| 9 | - | }: TabsPrimitive.TriggerProps = $props(); | |||
| 10 | - | </script> | |||
| 11 | - | ||||
| 12 | - | <TabsPrimitive.Trigger | |||
| 13 | - | bind:ref | |||
| 14 | - | data-slot="tabs-trigger" | |||
| 15 | - | class={cn( | |||
| 16 | - | "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | |||
| 17 | - | className | |||
| 18 | - | )} | |||
| 19 | - | {...restProps} | |||
| 20 | - | /> | |||
D webui/src/lib/components/ui/tabs/tabs.svelte +0 -19
| @@ -1,19 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { Tabs as TabsPrimitive } from "bits-ui"; | |||
| 3 | - | import { cn } from "$lib/utils.js"; | |||
| 4 | - | ||||
| 5 | - | let { | |||
| 6 | - | ref = $bindable(null), | |||
| 7 | - | value = $bindable(""), | |||
| 8 | - | class: className, | |||
| 9 | - | ...restProps | |||
| 10 | - | }: TabsPrimitive.RootProps = $props(); | |||
| 11 | - | </script> | |||
| 12 | - | ||||
| 13 | - | <TabsPrimitive.Root | |||
| 14 | - | bind:ref | |||
| 15 | - | bind:value | |||
| 16 | - | data-slot="tabs" | |||
| 17 | - | class={cn("flex flex-col gap-2", className)} | |||
| 18 | - | {...restProps} | |||
| 19 | - | /> | |||
D webui/src/lib/types.ts +0 -68
| @@ -1,68 +0,0 @@ | |||||
| 1 | - | import type { HistoryEntry, SearchShortcut } from "../types/generated.js"; | |||
| 2 | - | ||||
| 3 | - | type ThemeManagerAPI = { | |||
| 4 | - | setUserPreference: (theme: "light" | "dark") => void; | |||
| 5 | - | getCurrentTheme: () => "light" | "dark"; | |||
| 6 | - | }; | |||
| 7 | - | ||||
| 8 | - | // Global window API types for browser integration | |||
| 9 | - | declare global { | |||
| 10 | - | interface Window { | |||
| 11 | - | // Toast system API | |||
| 12 | - | __dumber_toast_loaded?: boolean; | |||
| 13 | - | __dumber_showToast?: ( | |||
| 14 | - | message: string, | |||
| 15 | - | duration?: number, | |||
| 16 | - | type?: "info" | "success" | "error", | |||
| 17 | - | ) => number | void; | |||
| 18 | - | __dumber_dismissToast?: (id: number) => void; | |||
| 19 | - | __dumber_clearToasts?: () => void; | |||
| 20 | - | __dumber_showZoomToast?: (zoomLevel: number) => void; | |||
| 21 | - | ||||
| 22 | - | // Theme integration | |||
| 23 | - | __dumber_initial_theme?: string; | |||
| 24 | - | __dumber_setTheme?: (theme: "light" | "dark") => void; | |||
| 25 | - | __dumber_color_scheme_manager?: ThemeManagerAPI; | |||
| 26 | - | ||||
| 27 | - | // History callbacks | |||
| 28 | - | __dumber_history_recent?: (data: HistoryEntry[]) => void; | |||
| 29 | - | __dumber_history_error?: (error: string) => void; | |||
| 30 | - | ||||
| 31 | - | // Shortcuts callbacks | |||
| 32 | - | __dumber_shortcuts_loaded?: (data: Record<string, SearchShortcut>) => void; | |||
| 33 | - | __dumber_shortcuts_error?: (error: string) => void; | |||
| 34 | - | ||||
| 35 | - | // WebKit message handler | |||
| 36 | - | webkit?: { | |||
| 37 | - | messageHandlers?: { | |||
| 38 | - | dumber?: { | |||
| 39 | - | postMessage: (message: unknown) => void; | |||
| 40 | - | }; | |||
| 41 | - | }; | |||
| 42 | - | }; | |||
| 43 | - | } | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | export interface ToastAPI { | |||
| 47 | - | show: ( | |||
| 48 | - | message: string, | |||
| 49 | - | duration?: number, | |||
| 50 | - | type?: "info" | "success" | "error", | |||
| 51 | - | ) => number; | |||
| 52 | - | dismiss: (id: number) => void; | |||
| 53 | - | clear: () => void; | |||
| 54 | - | showZoom: (zoomLevel: number) => void; | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | export interface ThemeAPI { | |||
| 58 | - | setTheme: (theme: "light" | "dark") => void; | |||
| 59 | - | getCurrentTheme: () => "light" | "dark"; | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | // Message types for WebKit communication | |||
| 63 | - | export interface BrowserMessage { | |||
| 64 | - | action: string; | |||
| 65 | - | data?: unknown; | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | export {}; // Make this file a module | |||
D webui/src/lib/utils.ts +0 -13
| @@ -1,13 +0,0 @@ | |||||
| 1 | - | import { clsx, type ClassValue } from "clsx"; | |||
| 2 | - | import { twMerge } from "tailwind-merge"; | |||
| 3 | - | ||||
| 4 | - | export function cn(...inputs: ClassValue[]) { | |||
| 5 | - | return twMerge(clsx(inputs)); | |||
| 6 | - | } | |||
| 7 | - | ||||
| 8 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 9 | - | export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T; | |||
| 10 | - | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| 11 | - | export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T; | |||
| 12 | - | export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>; | |||
| 13 | - | export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null }; | |||
D webui/src/lib/utils/keys.ts +0 -51
| @@ -1,51 +0,0 @@ | |||||
| 1 | - | /** | |||
| 2 | - | * Format a keybinding string for display. | |||
| 3 | - | * Converts internal key names to user-friendly display format. | |||
| 4 | - | * | |||
| 5 | - | * Examples: | |||
| 6 | - | * "ctrl+shift+a" -> "Ctrl + Shift + A" | |||
| 7 | - | * "arrowleft" -> "Left" | |||
| 8 | - | * "alt+[" -> "Alt + [" | |||
| 9 | - | */ | |||
| 10 | - | export function formatKey(key: string): string { | |||
| 11 | - | // Split by '+' separator first, then format each part | |||
| 12 | - | // This avoids replacing '+' characters that are actual key names | |||
| 13 | - | const parts = key.split("+"); | |||
| 14 | - | ||||
| 15 | - | const formattedParts = parts.map((part) => { | |||
| 16 | - | const lower = part.toLowerCase(); | |||
| 17 | - | switch (lower) { | |||
| 18 | - | case "arrowleft": | |||
| 19 | - | return "Left"; | |||
| 20 | - | case "arrowright": | |||
| 21 | - | return "Right"; | |||
| 22 | - | case "arrowup": | |||
| 23 | - | return "Up"; | |||
| 24 | - | case "arrowdown": | |||
| 25 | - | return "Down"; | |||
| 26 | - | case "ctrl": | |||
| 27 | - | return "Ctrl"; | |||
| 28 | - | case "alt": | |||
| 29 | - | return "Alt"; | |||
| 30 | - | case "shift": | |||
| 31 | - | return "Shift"; | |||
| 32 | - | case "space": | |||
| 33 | - | return "Space"; | |||
| 34 | - | case "enter": | |||
| 35 | - | return "Enter"; | |||
| 36 | - | case "tab": | |||
| 37 | - | return "Tab"; | |||
| 38 | - | case "backspace": | |||
| 39 | - | return "Backspace"; | |||
| 40 | - | case "escape": | |||
| 41 | - | return "Esc"; | |||
| 42 | - | default: | |||
| 43 | - | // Capitalize single letters, leave symbols as-is | |||
| 44 | - | return part.length === 1 && /[a-z]/i.test(part) | |||
| 45 | - | ? part.toUpperCase() | |||
| 46 | - | : part; | |||
| 47 | - | } | |||
| 48 | - | }); | |||
| 49 | - | ||||
| 50 | - | return formattedParts.join(" + "); | |||
| 51 | - | } | |||
D webui/src/pages/ConfigPage.svelte +0 -761
| @@ -1,761 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { onMount } from "svelte"; | |||
| 3 | - | import { ModeWatcher, resetMode, setMode } from "mode-watcher"; | |||
| 4 | - | import ConfigShell from "./config/ConfigShell.svelte"; | |||
| 5 | - | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |||
| 6 | - | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |||
| 7 | - | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |||
| 8 | - | import { Button } from "$lib/components/ui/button"; | |||
| 9 | - | import { ColorPicker } from "$lib/components/ui/color-picker"; | |||
| 10 | - | import { Input } from "$lib/components/ui/input"; | |||
| 11 | - | import { Label } from "$lib/components/ui/label"; | |||
| 12 | - | import { Spinner } from "$lib/components/ui/spinner"; | |||
| 13 | - | import * as Card from "$lib/components/ui/card"; | |||
| 14 | - | import * as Tabs from "$lib/components/ui/tabs"; | |||
| 15 | - | import { FlaskConical, RefreshCw } from "@lucide/svelte"; | |||
| 16 | - | ||||
| 17 | - | function getErrorMessage(e: unknown): string { | |||
| 18 | - | if (e instanceof Error) return e.message; | |||
| 19 | - | if (typeof e === "string") return e; | |||
| 20 | - | return "Unknown error"; | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | type ColorPalette = { | |||
| 24 | - | background: string; | |||
| 25 | - | surface: string; | |||
| 26 | - | surface_variant: string; | |||
| 27 | - | text: string; | |||
| 28 | - | muted: string; | |||
| 29 | - | accent: string; | |||
| 30 | - | border: string; | |||
| 31 | - | }; | |||
| 32 | - | ||||
| 33 | - | type SearchShortcut = { | |||
| 34 | - | url: string; | |||
| 35 | - | description: string; | |||
| 36 | - | }; | |||
| 37 | - | ||||
| 38 | - | type ResolvedPerformance = { | |||
| 39 | - | skia_cpu_threads: number; | |||
| 40 | - | skia_gpu_threads: number; | |||
| 41 | - | web_process_memory_mb: number; | |||
| 42 | - | network_process_memory_mb: number; | |||
| 43 | - | webview_pool_prewarm: number; | |||
| 44 | - | conservative_threshold: number; | |||
| 45 | - | strict_threshold: number; | |||
| 46 | - | }; | |||
| 47 | - | ||||
| 48 | - | type HardwareInfo = { | |||
| 49 | - | cpu_cores: number; | |||
| 50 | - | cpu_threads: number; | |||
| 51 | - | total_ram_mb: number; | |||
| 52 | - | gpu_vendor: string; | |||
| 53 | - | gpu_name: string; | |||
| 54 | - | vram_mb: number; | |||
| 55 | - | }; | |||
| 56 | - | ||||
| 57 | - | type CustomPerformance = { | |||
| 58 | - | skia_cpu_threads: number; | |||
| 59 | - | skia_gpu_threads: number; | |||
| 60 | - | web_process_memory_mb: number; | |||
| 61 | - | network_process_memory_mb: number; | |||
| 62 | - | webview_pool_prewarm: number; | |||
| 63 | - | }; | |||
| 64 | - | ||||
| 65 | - | type PerformanceConfig = { | |||
| 66 | - | profile: string; | |||
| 67 | - | custom: CustomPerformance; | |||
| 68 | - | resolved: ResolvedPerformance; | |||
| 69 | - | hardware: HardwareInfo; | |||
| 70 | - | }; | |||
| 71 | - | ||||
| 72 | - | type ConfigDTO = { | |||
| 73 | - | appearance: { | |||
| 74 | - | sans_font: string; | |||
| 75 | - | serif_font: string; | |||
| 76 | - | monospace_font: string; | |||
| 77 | - | default_font_size: number; | |||
| 78 | - | color_scheme: string; | |||
| 79 | - | light_palette: ColorPalette; | |||
| 80 | - | dark_palette: ColorPalette; | |||
| 81 | - | }; | |||
| 82 | - | performance: PerformanceConfig; | |||
| 83 | - | default_ui_scale: number; | |||
| 84 | - | default_search_engine: string; | |||
| 85 | - | search_shortcuts: Record<string, SearchShortcut>; | |||
| 86 | - | engine_type: string; | |||
| 87 | - | }; | |||
| 88 | - | ||||
| 89 | - | const PERFORMANCE_PROFILES = [ | |||
| 90 | - | { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." }, | |||
| 91 | - | { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." }, | |||
| 92 | - | { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." }, | |||
| 93 | - | { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." }, | |||
| 94 | - | ]; | |||
| 95 | - | ||||
| 96 | - | const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [ | |||
| 97 | - | { key: "background", label: "Background" }, | |||
| 98 | - | { key: "surface", label: "Surface" }, | |||
| 99 | - | { key: "surface_variant", label: "Surface Variant" }, | |||
| 100 | - | { key: "text", label: "Text" }, | |||
| 101 | - | { key: "muted", label: "Muted" }, | |||
| 102 | - | { key: "accent", label: "Accent" }, | |||
| 103 | - | { key: "border", label: "Border" }, | |||
| 104 | - | ]; | |||
| 105 | - | ||||
| 106 | - | function isHexColor(value: string): boolean { | |||
| 107 | - | return /^#[0-9a-fA-F]{6}$/.test(value); | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | // UI state | |||
| 111 | - | let loading = $state(true); | |||
| 112 | - | let loadError = $state<string | null>(null); | |||
| 113 | - | let config = $state<ConfigDTO | null>(null); | |||
| 114 | - | let saving = $state(false); | |||
| 115 | - | let saveSuccess = $state(false); | |||
| 116 | - | let saveError = $state<string | null>(null); | |||
| 117 | - | let showRestartWarning = $state(false); | |||
| 118 | - | let themeEvents = $state(0); | |||
| 119 | - | let activeTab = $state("appearance"); | |||
| 120 | - | ||||
| 121 | - | function getResolvedDarkPreference(fallback?: boolean): boolean { | |||
| 122 | - | if (typeof fallback === "boolean") { | |||
| 123 | - | return fallback; | |||
| 124 | - | } | |||
| 125 | - | if (typeof window.__dumber_cef_prefers_dark === "boolean") { | |||
| 126 | - | return window.__dumber_cef_prefers_dark; | |||
| 127 | - | } | |||
| 128 | - | if (typeof window.__dumber_gtk_prefers_dark === "boolean") { | |||
| 129 | - | return window.__dumber_gtk_prefers_dark; | |||
| 130 | - | } | |||
| 131 | - | return document.documentElement.classList.contains("dark"); | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | function applyResolvedTheme(prefersDark: boolean): void { | |||
| 135 | - | const root = document.documentElement; | |||
| 136 | - | root.classList.toggle("dark", prefersDark); | |||
| 137 | - | root.classList.toggle("light", !prefersDark); | |||
| 138 | - | root.style.colorScheme = prefersDark ? "dark" : "light"; | |||
| 139 | - | window.__dumber_cef_prefers_dark = prefersDark; | |||
| 140 | - | window.__dumber_gtk_prefers_dark = prefersDark; | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | function syncModeWatcherPreference(colorScheme: string | undefined, prefersDark?: boolean): void { | |||
| 144 | - | switch (colorScheme) { | |||
| 145 | - | case "prefer-dark": | |||
| 146 | - | setMode("dark"); | |||
| 147 | - | applyResolvedTheme(true); | |||
| 148 | - | break; | |||
| 149 | - | case "prefer-light": | |||
| 150 | - | setMode("light"); | |||
| 151 | - | applyResolvedTheme(false); | |||
| 152 | - | break; | |||
| 153 | - | default: | |||
| 154 | - | resetMode(); | |||
| 155 | - | applyResolvedTheme(getResolvedDarkPreference(prefersDark)); | |||
| 156 | - | break; | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | function isPerformanceSupported(cfg: ConfigDTO | null): boolean { | |||
| 161 | - | return cfg != null && cfg.engine_type !== "cef"; | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // Load config from API | |||
| 165 | - | async function loadConfig() { | |||
| 166 | - | loading = true; | |||
| 167 | - | loadError = null; | |||
| 168 | - | try { | |||
| 169 | - | const response = await fetch("/api/config"); | |||
| 170 | - | if (!response.ok) throw new Error("Failed to fetch config"); | |||
| 171 | - | config = (await response.json()) as ConfigDTO; | |||
| 172 | - | syncModeWatcherPreference(config.appearance.color_scheme); | |||
| 173 | - | if (!isPerformanceSupported(config) && activeTab === "performance") { | |||
| 174 | - | activeTab = "appearance"; | |||
| 175 | - | } | |||
| 176 | - | } catch (e: unknown) { | |||
| 177 | - | loadError = getErrorMessage(e); | |||
| 178 | - | console.error("[config] load failed", e); | |||
| 179 | - | } finally { | |||
| 180 | - | loading = false; | |||
| 181 | - | } | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | // Reload config (used after save to get normalized values) | |||
| 185 | - | function reloadConfig() { | |||
| 186 | - | loadConfig(); | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | let resetDialogOpen = $state(false); | |||
| 190 | - | ||||
| 191 | - | async function doResetToDefaults() { | |||
| 192 | - | saveSuccess = false; | |||
| 193 | - | try { | |||
| 194 | - | const response = await fetch("/api/config/default"); | |||
| 195 | - | if (!response.ok) throw new Error("Failed to fetch default config"); | |||
| 196 | - | config = (await response.json()) as ConfigDTO; | |||
| 197 | - | syncModeWatcherPreference(config.appearance.color_scheme); | |||
| 198 | - | if (!isPerformanceSupported(config) && activeTab === "performance") { | |||
| 199 | - | activeTab = "appearance"; | |||
| 200 | - | } | |||
| 201 | - | } catch (e: unknown) { | |||
| 202 | - | console.error("[config] reset defaults failed", e); | |||
| 203 | - | } | |||
| 204 | - | resetDialogOpen = false; | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |||
| 208 | - | const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber; | |||
| 209 | - | if (bridge && typeof bridge.postMessage === "function") { | |||
| 210 | - | return bridge; | |||
| 211 | - | } | |||
| 212 | - | return null; | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | function getWebViewId(): number { | |||
| 216 | - | return window.__dumber_webview_id || 0; | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | async function saveConfig() { | |||
| 220 | - | saving = true; | |||
| 221 | - | saveSuccess = false; | |||
| 222 | - | saveError = null; | |||
| 223 | - | ||||
| 224 | - | try { | |||
| 225 | - | const bridge = getWebKitBridge(); | |||
| 226 | - | const webviewId = getWebViewId(); | |||
| 227 | - | ||||
| 228 | - | console.debug("[config] save click", { | |||
| 229 | - | hasBridge: Boolean(bridge), | |||
| 230 | - | webviewId, | |||
| 231 | - | hasConfig: Boolean(config), | |||
| 232 | - | }); | |||
| 233 | - | ||||
| 234 | - | if (!bridge) { | |||
| 235 | - | throw new Error("Dumber bridge not available (not running inside Dumber)"); | |||
| 236 | - | } | |||
| 237 | - | if (!config) { | |||
| 238 | - | throw new Error("Config not loaded"); | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | let timeout: number | null = window.setTimeout(() => { | |||
| 242 | - | if (saving) { | |||
| 243 | - | console.warn("[config] save timed out (no callback)"); | |||
| 244 | - | saveError = "Save timed out (no response from native handler)"; | |||
| 245 | - | saving = false; | |||
| 246 | - | } | |||
| 247 | - | }, 8000); | |||
| 248 | - | ||||
| 249 | - | const clearSaveTimeout = () => { | |||
| 250 | - | if (timeout != null) { | |||
| 251 | - | window.clearTimeout(timeout); | |||
| 252 | - | timeout = null; | |||
| 253 | - | } | |||
| 254 | - | }; | |||
| 255 | - | ||||
| 256 | - | window.__dumber_config_saved = (resp?: unknown) => { | |||
| 257 | - | clearSaveTimeout(); | |||
| 258 | - | console.debug("[config] save success", resp); | |||
| 259 | - | saveSuccess = true; | |||
| 260 | - | saving = false; | |||
| 261 | - | ||||
| 262 | - | // Show restart warning if performance tab was active | |||
| 263 | - | if (activeTab === "performance" && isPerformanceSupported(config)) { | |||
| 264 | - | showRestartWarning = true; | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | syncModeWatcherPreference(config?.appearance.color_scheme); | |||
| 268 | - | ||||
| 269 | - | // Refresh config from backend (in case watcher normalized values) | |||
| 270 | - | reloadConfig(); | |||
| 271 | - | ||||
| 272 | - | setTimeout(() => { | |||
| 273 | - | saveSuccess = false; | |||
| 274 | - | }, 3000); | |||
| 275 | - | }; | |||
| 276 | - | window.__dumber_config_error = (msg: unknown) => { | |||
| 277 | - | clearSaveTimeout(); | |||
| 278 | - | console.error("[config] save error", msg); | |||
| 279 | - | saveError = typeof msg === "string" ? msg : "Failed to save config"; | |||
| 280 | - | saving = false; | |||
| 281 | - | }; | |||
| 282 | - | ||||
| 283 | - | const payload = $state.snapshot(config); | |||
| 284 | - | ||||
| 285 | - | try { | |||
| 286 | - | bridge.postMessage({ | |||
| 287 | - | type: "save_config", | |||
| 288 | - | webview_id: webviewId, | |||
| 289 | - | payload, | |||
| 290 | - | }); | |||
| 291 | - | } catch (postErr) { | |||
| 292 | - | console.error("[config] postMessage threw", postErr); | |||
| 293 | - | throw postErr; | |||
| 294 | - | } | |||
| 295 | - | } catch (e: unknown) { | |||
| 296 | - | console.error("[config] save exception", e); | |||
| 297 | - | saveError = getErrorMessage(e); | |||
| 298 | - | saving = false; | |||
| 299 | - | } | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) { | |||
| 303 | - | if (!config) return; | |||
| 304 | - | config.search_shortcuts = nextShortcuts; | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | - | onMount(() => { | |||
| 308 | - | console.debug("[config] mount", { | |||
| 309 | - | webviewId: window.__dumber_webview_id, | |||
| 310 | - | hasBridge: Boolean(getWebKitBridge()), | |||
| 311 | - | }); | |||
| 312 | - | ||||
| 313 | - | const onThemeChanged = (e: Event) => { | |||
| 314 | - | themeEvents += 1; | |||
| 315 | - | const detail = (e as CustomEvent<{ prefersDark?: boolean }>).detail; | |||
| 316 | - | syncModeWatcherPreference(config?.appearance.color_scheme, detail?.prefersDark); | |||
| 317 | - | console.debug("[config] theme changed", detail); | |||
| 318 | - | }; | |||
| 319 | - | window.addEventListener("dumber:theme-changed", onThemeChanged); | |||
| 320 | - | ||||
| 321 | - | // Load config on mount | |||
| 322 | - | loadConfig(); | |||
| 323 | - | ||||
| 324 | - | return () => { | |||
| 325 | - | window.removeEventListener("dumber:theme-changed", onThemeChanged); | |||
| 326 | - | }; | |||
| 327 | - | }); | |||
| 328 | - | </script> | |||
| 329 | - | ||||
| 330 | - | <ModeWatcher /> | |||
| 331 | - | <ConfigShell> | |||
| 332 | - | <div class="flex w-full flex-col gap-6 px-6 py-8"> | |||
| 333 | - | {#if loading} | |||
| 334 | - | <!-- Loading state with spinner --> | |||
| 335 | - | <div class="flex flex-col items-center justify-center gap-4 py-16"> | |||
| 336 | - | <Spinner class="size-8" /> | |||
| 337 | - | <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground"> | |||
| 338 | - | Loading configuration… | |||
| 339 | - | </div> | |||
| 340 | - | </div> | |||
| 341 | - | {:else if loadError} | |||
| 342 | - | <!-- Error state --> | |||
| 343 | - | <div class="flex flex-col items-center justify-center gap-4 py-16"> | |||
| 344 | - | <Card.Root class="max-w-md"> | |||
| 345 | - | <Card.Header> | |||
| 346 | - | <Card.Title class="text-destructive">Failed to load config</Card.Title> | |||
| 347 | - | <Card.Description>{loadError}</Card.Description> | |||
| 348 | - | </Card.Header> | |||
| 349 | - | <Card.Content> | |||
| 350 | - | <Button variant="outline" onclick={reloadConfig}>Try again</Button> | |||
| 351 | - | </Card.Content> | |||
| 352 | - | </Card.Root> | |||
| 353 | - | </div> | |||
| 354 | - | {:else if config} | |||
| 355 | - | <Tabs.Root bind:value={activeTab} class="w-full"> | |||
| 356 | - | <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> | |||
| 357 | - | <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground"> | |||
| 358 | - | Config Workspace | |||
| 359 | - | </div> | |||
| 360 | - | <Tabs.List class="bg-muted/60"> | |||
| 361 | - | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |||
| 362 | - | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |||
| 363 | - | <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger> | |||
| 364 | - | <Tabs.Trigger | |||
| 365 | - | value="performance" | |||
| 366 | - | disabled={!isPerformanceSupported(config)} | |||
| 367 | - | title={!isPerformanceSupported(config) ? "Performance tuning is only available with the WebKit engine" : undefined} | |||
| 368 | - | > | |||
| 369 | - | Performance | |||
| 370 | - | </Tabs.Trigger> | |||
| 371 | - | </Tabs.List> | |||
| 372 | - | </div> | |||
| 373 | - | ||||
| 374 | - | {#if !isPerformanceSupported(config)} | |||
| 375 | - | <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground"> | |||
| 376 | - | Performance settings are disabled while using the CEF engine because these options are specific to WebKitGTK. | |||
| 377 | - | </div> | |||
| 378 | - | {/if} | |||
| 379 | - | ||||
| 380 | - | <Tabs.Content value="appearance"> | |||
| 381 | - | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 382 | - | <Card.Header> | |||
| 383 | - | <Card.Title>Appearance</Card.Title> | |||
| 384 | - | <Card.Description>Fonts, palette, and theme preferences.</Card.Description> | |||
| 385 | - | </Card.Header> | |||
| 386 | - | <Card.Content class="space-y-8"> | |||
| 387 | - | <div class="grid gap-6 md:grid-cols-2"> | |||
| 388 | - | <div class="space-y-2"> | |||
| 389 | - | <Label for="sans_font">Sans-Serif Font</Label> | |||
| 390 | - | <Input id="sans_font" bind:value={config.appearance.sans_font} /> | |||
| 391 | - | </div> | |||
| 392 | - | <div class="space-y-2"> | |||
| 393 | - | <Label for="serif_font">Serif Font</Label> | |||
| 394 | - | <Input id="serif_font" bind:value={config.appearance.serif_font} /> | |||
| 395 | - | </div> | |||
| 396 | - | <div class="space-y-2"> | |||
| 397 | - | <Label for="monospace_font">Monospace Font</Label> | |||
| 398 | - | <Input id="monospace_font" bind:value={config.appearance.monospace_font} /> | |||
| 399 | - | </div> | |||
| 400 | - | <div class="space-y-2"> | |||
| 401 | - | <Label for="font_size">Default Font Size</Label> | |||
| 402 | - | <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} /> | |||
| 403 | - | </div> | |||
| 404 | - | <div class="space-y-2"> | |||
| 405 | - | <Label for="color_scheme">Color Scheme</Label> | |||
| 406 | - | <select | |||
| 407 | - | id="color_scheme" | |||
| 408 | - | bind:value={config.appearance.color_scheme} | |||
| 409 | - | class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |||
| 410 | - | > | |||
| 411 | - | <option value="default">Follow System</option> | |||
| 412 | - | <option value="prefer-dark">Always Dark</option> | |||
| 413 | - | <option value="prefer-light">Always Light</option> | |||
| 414 | - | </select> | |||
| 415 | - | </div> | |||
| 416 | - | <div class="space-y-2"> | |||
| 417 | - | <Label for="ui_scale">UI Scale</Label> | |||
| 418 | - | <Input | |||
| 419 | - | id="ui_scale" | |||
| 420 | - | type="number" | |||
| 421 | - | step="0.1" | |||
| 422 | - | bind:value={config.default_ui_scale} | |||
| 423 | - | /> | |||
| 424 | - | </div> | |||
| 425 | - | </div> | |||
| 426 | - | ||||
| 427 | - | <div class="space-y-6"> | |||
| 428 | - | <div class="space-y-3"> | |||
| 429 | - | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |||
| 430 | - | Theme: Light | |||
| 431 | - | </div> | |||
| 432 | - | <div class="grid gap-4 md:grid-cols-2"> | |||
| 433 | - | {#each PALETTE_FIELDS as field (field.key)} | |||
| 434 | - | <div class="space-y-2"> | |||
| 435 | - | <Label for={`light_${field.key}`}>{field.label}</Label> | |||
| 436 | - | <div class="flex items-center gap-3"> | |||
| 437 | - | <ColorPicker | |||
| 438 | - | id={`light_${field.key}`} | |||
| 439 | - | value={config.appearance.light_palette[field.key]} | |||
| 440 | - | onValueChange={(v) => { | |||
| 441 | - | config!.appearance.light_palette[field.key] = v; | |||
| 442 | - | }} | |||
| 443 | - | /> | |||
| 444 | - | <Input bind:value={config.appearance.light_palette[field.key]} /> | |||
| 445 | - | </div> | |||
| 446 | - | {#if !isHexColor(config.appearance.light_palette[field.key])} | |||
| 447 | - | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 448 | - | {/if} | |||
| 449 | - | </div> | |||
| 450 | - | {/each} | |||
| 451 | - | </div> | |||
| 452 | - | </div> | |||
| 453 | - | ||||
| 454 | - | <div class="space-y-3"> | |||
| 455 | - | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |||
| 456 | - | Theme: Dark | |||
| 457 | - | </div> | |||
| 458 | - | <div class="grid gap-4 md:grid-cols-2"> | |||
| 459 | - | {#each PALETTE_FIELDS as field (field.key)} | |||
| 460 | - | <div class="space-y-2"> | |||
| 461 | - | <Label for={`dark_${field.key}`}>{field.label}</Label> | |||
| 462 | - | <div class="flex items-center gap-3"> | |||
| 463 | - | <ColorPicker | |||
| 464 | - | id={`dark_${field.key}`} | |||
| 465 | - | value={config.appearance.dark_palette[field.key]} | |||
| 466 | - | onValueChange={(v) => { | |||
| 467 | - | config!.appearance.dark_palette[field.key] = v; | |||
| 468 | - | }} | |||
| 469 | - | /> | |||
| 470 | - | <Input bind:value={config.appearance.dark_palette[field.key]} /> | |||
| 471 | - | </div> | |||
| 472 | - | {#if !isHexColor(config.appearance.dark_palette[field.key])} | |||
| 473 | - | <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p> | |||
| 474 | - | {/if} | |||
| 475 | - | </div> | |||
| 476 | - | {/each} | |||
| 477 | - | </div> | |||
| 478 | - | </div> | |||
| 479 | - | </div> | |||
| 480 | - | </Card.Content> | |||
| 481 | - | </Card.Root> | |||
| 482 | - | </Tabs.Content> | |||
| 483 | - | ||||
| 484 | - | <Tabs.Content value="search"> | |||
| 485 | - | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 486 | - | <Card.Header> | |||
| 487 | - | <Card.Title>Search</Card.Title> | |||
| 488 | - | <Card.Description>Default engine and shortcuts.</Card.Description> | |||
| 489 | - | </Card.Header> | |||
| 490 | - | <Card.Content class="space-y-8"> | |||
| 491 | - | <div class="space-y-2"> | |||
| 492 | - | <Label for="search_engine">Default Search Engine (use %s for query)</Label> | |||
| 493 | - | <Input id="search_engine" bind:value={config.default_search_engine} /> | |||
| 494 | - | </div> | |||
| 495 | - | ||||
| 496 | - | <div class="space-y-3"> | |||
| 497 | - | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |||
| 498 | - | Search Shortcuts | |||
| 499 | - | </div> | |||
| 500 | - | <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p> | |||
| 501 | - | <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} /> | |||
| 502 | - | </div> | |||
| 503 | - | </Card.Content> | |||
| 504 | - | </Card.Root> | |||
| 505 | - | </Tabs.Content> | |||
| 506 | - | ||||
| 507 | - | <Tabs.Content value="keybindings"> | |||
| 508 | - | <KeybindingsTab /> | |||
| 509 | - | </Tabs.Content> | |||
| 510 | - | ||||
| 511 | - | <Tabs.Content value="performance"> | |||
| 512 | - | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 513 | - | <Card.Header> | |||
| 514 | - | <Card.Title>Performance Profile</Card.Title> | |||
| 515 | - | <Card.Description> | |||
| 516 | - | WebKitGTK rendering and memory tuning presets. | |||
| 517 | - | </Card.Description> | |||
| 518 | - | </Card.Header> | |||
| 519 | - | <Card.Content class="space-y-6"> | |||
| 520 | - | {#if !isPerformanceSupported(config)} | |||
| 521 | - | <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground"> | |||
| 522 | - | This section is unavailable under the CEF engine. | |||
| 523 | - | </div> | |||
| 524 | - | {:else} | |||
| 525 | - | <!-- Detected Hardware Card --> | |||
| 526 | - | {#if config.performance?.hardware} | |||
| 527 | - | {@const hw = config.performance.hardware} | |||
| 528 | - | <div class="rounded-md border border-border bg-muted/30 px-4 py-3"> | |||
| 529 | - | <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 530 | - | Detected Hardware | |||
| 531 | - | </div> | |||
| 532 | - | <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm"> | |||
| 533 | - | <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div> | |||
| 534 | - | <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div> | |||
| 535 | - | <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div> | |||
| 536 | - | <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div> | |||
| 537 | - | </div> | |||
| 538 | - | </div> | |||
| 539 | - | {/if} | |||
| 540 | - | ||||
| 541 | - | <!-- Experimental feature warning --> | |||
| 542 | - | <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3"> | |||
| 543 | - | <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" /> | |||
| 544 | - | <div class="space-y-1"> | |||
| 545 | - | <p class="text-sm font-medium text-info">Experimental Feature</p> | |||
| 546 | - | <p class="text-xs text-muted-foreground"> | |||
| 547 | - | Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version. | |||
| 548 | - | </p> | |||
| 549 | - | </div> | |||
| 550 | - | </div> | |||
| 551 | - | ||||
| 552 | - | <!-- Profile selector --> | |||
| 553 | - | {#if config.performance} | |||
| 554 | - | <div class="space-y-4"> | |||
| 555 | - | {#each PERFORMANCE_PROFILES as profile (profile.value)} | |||
| 556 | - | <label | |||
| 557 | - | class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}" | |||
| 558 | - | > | |||
| 559 | - | <input | |||
| 560 | - | type="radio" | |||
| 561 | - | name="performance_profile" | |||
| 562 | - | value={profile.value} | |||
| 563 | - | checked={config.performance.profile === profile.value} | |||
| 564 | - | onchange={() => config!.performance.profile = profile.value} | |||
| 565 | - | class="mt-1 accent-primary" | |||
| 566 | - | /> | |||
| 567 | - | <div class="space-y-1"> | |||
| 568 | - | <div class="font-medium">{profile.label}</div> | |||
| 569 | - | <div class="text-sm text-muted-foreground">{profile.description}</div> | |||
| 570 | - | </div> | |||
| 571 | - | </label> | |||
| 572 | - | {/each} | |||
| 573 | - | </div> | |||
| 574 | - | {:else} | |||
| 575 | - | <div class="text-sm text-muted-foreground"> | |||
| 576 | - | Performance settings not available. This may be due to a configuration error. | |||
| 577 | - | </div> | |||
| 578 | - | {/if} | |||
| 579 | - | ||||
| 580 | - | <!-- Custom profile fields --> | |||
| 581 | - | {#if config.performance?.profile === "custom"} | |||
| 582 | - | <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4"> | |||
| 583 | - | <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 584 | - | Custom Settings | |||
| 585 | - | </div> | |||
| 586 | - | <div class="grid gap-4 md:grid-cols-2"> | |||
| 587 | - | <div class="space-y-2"> | |||
| 588 | - | <Label for="skia_cpu_threads">Skia CPU Threads</Label> | |||
| 589 | - | <Input | |||
| 590 | - | id="skia_cpu_threads" | |||
| 591 | - | type="number" | |||
| 592 | - | min="0" | |||
| 593 | - | max="8" | |||
| 594 | - | bind:value={config.performance.custom.skia_cpu_threads} | |||
| 595 | - | /> | |||
| 596 | - | <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p> | |||
| 597 | - | </div> | |||
| 598 | - | <div class="space-y-2"> | |||
| 599 | - | <Label for="skia_gpu_threads">Skia GPU Threads</Label> | |||
| 600 | - | <Input | |||
| 601 | - | id="skia_gpu_threads" | |||
| 602 | - | type="number" | |||
| 603 | - | min="-1" | |||
| 604 | - | bind:value={config.performance.custom.skia_gpu_threads} | |||
| 605 | - | /> | |||
| 606 | - | <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p> | |||
| 607 | - | </div> | |||
| 608 | - | <div class="space-y-2"> | |||
| 609 | - | <Label for="web_process_memory">Web Process Memory (MB)</Label> | |||
| 610 | - | <Input | |||
| 611 | - | id="web_process_memory" | |||
| 612 | - | type="number" | |||
| 613 | - | min="0" | |||
| 614 | - | step="256" | |||
| 615 | - | bind:value={config.performance.custom.web_process_memory_mb} | |||
| 616 | - | /> | |||
| 617 | - | <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p> | |||
| 618 | - | </div> | |||
| 619 | - | <div class="space-y-2"> | |||
| 620 | - | <Label for="network_process_memory">Network Process Memory (MB)</Label> | |||
| 621 | - | <Input | |||
| 622 | - | id="network_process_memory" | |||
| 623 | - | type="number" | |||
| 624 | - | min="0" | |||
| 625 | - | step="128" | |||
| 626 | - | bind:value={config.performance.custom.network_process_memory_mb} | |||
| 627 | - | /> | |||
| 628 | - | <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p> | |||
| 629 | - | </div> | |||
| 630 | - | <div class="space-y-2"> | |||
| 631 | - | <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label> | |||
| 632 | - | <Input | |||
| 633 | - | id="webview_pool_prewarm" | |||
| 634 | - | type="number" | |||
| 635 | - | min="0" | |||
| 636 | - | max="20" | |||
| 637 | - | bind:value={config.performance.custom.webview_pool_prewarm} | |||
| 638 | - | /> | |||
| 639 | - | <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p> | |||
| 640 | - | </div> | |||
| 641 | - | </div> | |||
| 642 | - | </div> | |||
| 643 | - | {/if} | |||
| 644 | - | ||||
| 645 | - | <!-- Resolved values display --> | |||
| 646 | - | {#if config.performance?.resolved} | |||
| 647 | - | <div class="space-y-3"> | |||
| 648 | - | <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground"> | |||
| 649 | - | Resolved Settings (applied on restart) | |||
| 650 | - | </div> | |||
| 651 | - | <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm"> | |||
| 652 | - | <div class="space-y-1"> | |||
| 653 | - | <div class="flex justify-between"> | |||
| 654 | - | <span class="font-medium">Skia CPU Threads</span> | |||
| 655 | - | <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span> | |||
| 656 | - | </div> | |||
| 657 | - | <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p> | |||
| 658 | - | </div> | |||
| 659 | - | <div class="space-y-1"> | |||
| 660 | - | <div class="flex justify-between"> | |||
| 661 | - | <span class="font-medium">Skia GPU Threads</span> | |||
| 662 | - | <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span> | |||
| 663 | - | </div> | |||
| 664 | - | <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p> | |||
| 665 | - | </div> | |||
| 666 | - | <div class="space-y-1"> | |||
| 667 | - | <div class="flex justify-between"> | |||
| 668 | - | <span class="font-medium">Web Process Memory</span> | |||
| 669 | - | <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span> | |||
| 670 | - | </div> | |||
| 671 | - | <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p> | |||
| 672 | - | </div> | |||
| 673 | - | <div class="space-y-1"> | |||
| 674 | - | <div class="flex justify-between"> | |||
| 675 | - | <span class="font-medium">Network Process Memory</span> | |||
| 676 | - | <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span> | |||
| 677 | - | </div> | |||
| 678 | - | <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p> | |||
| 679 | - | </div> | |||
| 680 | - | <div class="space-y-1"> | |||
| 681 | - | <div class="flex justify-between"> | |||
| 682 | - | <span class="font-medium">WebView Pool Prewarm</span> | |||
| 683 | - | <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span> | |||
| 684 | - | </div> | |||
| 685 | - | <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p> | |||
| 686 | - | </div> | |||
| 687 | - | </div> | |||
| 688 | - | </div> | |||
| 689 | - | {/if} | |||
| 690 | - | {/if} | |||
| 691 | - | </Card.Content> | |||
| 692 | - | </Card.Root> | |||
| 693 | - | </Tabs.Content> | |||
| 694 | - | </Tabs.Root> | |||
| 695 | - | ||||
| 696 | - | <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4"> | |||
| 697 | - | <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground"> | |||
| 698 | - | {#if saveSuccess} | |||
| 699 | - | <span class="text-primary">Saved</span> | |||
| 700 | - | {:else} | |||
| 701 | - | <span>Ready</span> | |||
| 702 | - | {/if} | |||
| 703 | - | {#if themeEvents > 0} | |||
| 704 | - | <span>Theme x{themeEvents}</span> | |||
| 705 | - | {/if} | |||
| 706 | - | </div> | |||
| 707 | - | {#if activeTab !== "keybindings"} | |||
| 708 | - | <div class="flex items-center gap-3"> | |||
| 709 | - | <AlertDialog.Root bind:open={resetDialogOpen}> | |||
| 710 | - | <AlertDialog.Trigger disabled={saving}> | |||
| 711 | - | {#snippet child({ props })} | |||
| 712 | - | <Button variant="outline" {...props} type="button"> | |||
| 713 | - | Reset Defaults | |||
| 714 | - | </Button> | |||
| 715 | - | {/snippet} | |||
| 716 | - | </AlertDialog.Trigger> | |||
| 717 | - | <AlertDialog.Content> | |||
| 718 | - | <AlertDialog.Header> | |||
| 719 | - | <AlertDialog.Title>Reset to defaults?</AlertDialog.Title> | |||
| 720 | - | <AlertDialog.Description> | |||
| 721 | - | This will reset all settings on this page to their default values. | |||
| 722 | - | You will still need to click Save to persist the changes. | |||
| 723 | - | </AlertDialog.Description> | |||
| 724 | - | </AlertDialog.Header> | |||
| 725 | - | <AlertDialog.Footer> | |||
| 726 | - | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |||
| 727 | - | <AlertDialog.Action onclick={doResetToDefaults}> | |||
| 728 | - | Reset Defaults | |||
| 729 | - | </AlertDialog.Action> | |||
| 730 | - | </AlertDialog.Footer> | |||
| 731 | - | </AlertDialog.Content> | |||
| 732 | - | </AlertDialog.Root> | |||
| 733 | - | <Button onclick={saveConfig} disabled={saving} type="button"> | |||
| 734 | - | {saving ? "Saving…" : "Save"} | |||
| 735 | - | </Button> | |||
| 736 | - | </div> | |||
| 737 | - | {/if} | |||
| 738 | - | </div> | |||
| 739 | - | ||||
| 740 | - | <!-- Save error display --> | |||
| 741 | - | {#if saveError} | |||
| 742 | - | <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |||
| 743 | - | {saveError} | |||
| 744 | - | </div> | |||
| 745 | - | {/if} | |||
| 746 | - | ||||
| 747 | - | <!-- Restart warning after saving performance settings --> | |||
| 748 | - | {#if showRestartWarning} | |||
| 749 | - | <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3"> | |||
| 750 | - | <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" /> | |||
| 751 | - | <div class="space-y-1"> | |||
| 752 | - | <p class="text-sm font-medium text-warning">Restart Required</p> | |||
| 753 | - | <p class="text-xs text-muted-foreground"> | |||
| 754 | - | Performance settings are applied at browser startup. Restart Dumber for changes to take effect. | |||
| 755 | - | </p> | |||
| 756 | - | </div> | |||
| 757 | - | </div> | |||
| 758 | - | {/if} | |||
| 759 | - | {/if} | |||
| 760 | - | </div> | |||
| 761 | - | </ConfigShell> | |||
D webui/src/pages/ErrorPage.svelte +0 -213
| @@ -1,213 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { onMount } from 'svelte'; | |||
| 3 | - | import { ModeWatcher } from 'mode-watcher'; | |||
| 4 | - | import { Button } from '$lib/components/ui/button'; | |||
| 5 | - | import * as Card from '$lib/components/ui/card'; | |||
| 6 | - | ||||
| 7 | - | type ErrorConfig = { | |||
| 8 | - | code: number; | |||
| 9 | - | title: string; | |||
| 10 | - | message: string; | |||
| 11 | - | icon: string; | |||
| 12 | - | actions: string[]; | |||
| 13 | - | }; | |||
| 14 | - | ||||
| 15 | - | const defaultConfig: ErrorConfig = { | |||
| 16 | - | code: 404, | |||
| 17 | - | title: 'Page Not Found', | |||
| 18 | - | message: 'The page you are looking for does not exist.', | |||
| 19 | - | icon: 'warning', | |||
| 20 | - | actions: ['back'], | |||
| 21 | - | }; | |||
| 22 | - | ||||
| 23 | - | // Error configurations | |||
| 24 | - | const errorConfigs: Record<string, ErrorConfig> = { | |||
| 25 | - | blocked: { code: 0, title: 'Page Blocked', message: 'Content blocked by filter', icon: 'shield', actions: ['whitelist', 'bypass', 'back'] }, | |||
| 26 | - | '404': defaultConfig, | |||
| 27 | - | '500': { code: 500, title: 'Server Error', message: 'Something went wrong on the server.', icon: 'error', actions: ['retry', 'back'] }, | |||
| 28 | - | '502': { code: 502, title: 'Bad Gateway', message: 'The server received an invalid response.', icon: 'error', actions: ['retry', 'back'] }, | |||
| 29 | - | '503': { code: 503, title: 'Service Unavailable', message: 'The server is temporarily unavailable.', icon: 'error', actions: ['retry', 'back'] }, | |||
| 30 | - | offline: { code: 0, title: 'You Are Offline', message: 'Check your internet connection.', icon: 'offline', actions: ['retry', 'back'] }, | |||
| 31 | - | ssl: { code: 0, title: 'Connection Not Secure', message: 'The security certificate is invalid.', icon: 'lock', actions: ['bypass', 'back'] }, | |||
| 32 | - | timeout: { code: 0, title: 'Connection Timed Out', message: 'The server took too long to respond.', icon: 'warning', actions: ['retry', 'back'] }, | |||
| 33 | - | }; | |||
| 34 | - | ||||
| 35 | - | let config = $state<ErrorConfig>(defaultConfig); | |||
| 36 | - | let targetUrl = $state(''); | |||
| 37 | - | let targetDomain = $state(''); | |||
| 38 | - | let customMessage = $state(''); | |||
| 39 | - | let isLoading = $state(false); | |||
| 40 | - | let actionError = $state(''); | |||
| 41 | - | ||||
| 42 | - | onMount(() => { | |||
| 43 | - | const params = new URLSearchParams(window.location.search); | |||
| 44 | - | const typeParam = params.get('type') || params.get('code') || '404'; | |||
| 45 | - | ||||
| 46 | - | const selectedConfig = errorConfigs[typeParam]; | |||
| 47 | - | config = selectedConfig ?? { | |||
| 48 | - | code: parseInt(typeParam) || 0, | |||
| 49 | - | title: `Error ${typeParam}`, | |||
| 50 | - | message: 'An unexpected error occurred.', | |||
| 51 | - | icon: 'error', | |||
| 52 | - | actions: ['retry', 'back'], | |||
| 53 | - | }; | |||
| 54 | - | ||||
| 55 | - | targetUrl = params.get('url') || ''; | |||
| 56 | - | customMessage = params.get('reason') || params.get('message') || ''; | |||
| 57 | - | ||||
| 58 | - | if (targetUrl) { | |||
| 59 | - | try { | |||
| 60 | - | targetDomain = new URL(targetUrl).hostname; | |||
| 61 | - | } catch { | |||
| 62 | - | targetDomain = targetUrl; | |||
| 63 | - | } | |||
| 64 | - | } | |||
| 65 | - | }); | |||
| 66 | - | ||||
| 67 | - | function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> { | |||
| 68 | - | return new Promise((resolve, reject) => { | |||
| 69 | - | const requestId = `error_${Date.now()}_${Math.random().toString(36).slice(2)}`; | |||
| 70 | - | const handler = (event: MessageEvent) => { | |||
| 71 | - | if (event.data?.requestId === requestId) { | |||
| 72 | - | window.removeEventListener('message', handler); | |||
| 73 | - | if (event.data.success) { | |||
| 74 | - | resolve(event.data.data); | |||
| 75 | - | } else { | |||
| 76 | - | reject(new Error(event.data.error || 'Unknown error')); | |||
| 77 | - | } | |||
| 78 | - | } | |||
| 79 | - | }; | |||
| 80 | - | window.addEventListener('message', handler); | |||
| 81 | - | if (window.webkit?.messageHandlers?.dumber) { | |||
| 82 | - | window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ type, requestId, payload })); | |||
| 83 | - | } else { | |||
| 84 | - | reject(new Error('WebKit message handler not available')); | |||
| 85 | - | } | |||
| 86 | - | setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Timeout')); }, 10000); | |||
| 87 | - | }); | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | async function addToWhitelist() { | |||
| 91 | - | if (!targetDomain) return; | |||
| 92 | - | isLoading = true; | |||
| 93 | - | actionError = ''; | |||
| 94 | - | try { | |||
| 95 | - | await sendMessage('addToWhitelist', { domain: targetDomain }); | |||
| 96 | - | window.location.href = targetUrl; | |||
| 97 | - | } catch (e) { | |||
| 98 | - | actionError = e instanceof Error ? e.message : 'Failed'; | |||
| 99 | - | isLoading = false; | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | async function bypassOnce() { | |||
| 104 | - | if (!targetUrl) return; | |||
| 105 | - | isLoading = true; | |||
| 106 | - | actionError = ''; | |||
| 107 | - | try { | |||
| 108 | - | await sendMessage('bypassOnce', { url: targetUrl }); | |||
| 109 | - | window.location.href = targetUrl; | |||
| 110 | - | } catch (e) { | |||
| 111 | - | actionError = e instanceof Error ? e.message : 'Failed'; | |||
| 112 | - | isLoading = false; | |||
| 113 | - | } | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | function retry() { | |||
| 117 | - | if (targetUrl) { | |||
| 118 | - | window.location.href = targetUrl; | |||
| 119 | - | } else { | |||
| 120 | - | window.location.reload(); | |||
| 121 | - | } | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | function goBack() { | |||
| 125 | - | window.history.back(); | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | const icons: Record<string, string> = { | |||
| 129 | - | shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>', | |||
| 130 | - | warning: '<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>', | |||
| 131 | - | error: '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>', | |||
| 132 | - | offline: '<line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>', | |||
| 133 | - | lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', | |||
| 134 | - | }; | |||
| 135 | - | ||||
| 136 | - | const iconColors: Record<string, string> = { | |||
| 137 | - | shield: 'text-primary', | |||
| 138 | - | warning: 'text-yellow-500', | |||
| 139 | - | error: 'text-destructive', | |||
| 140 | - | offline: 'text-muted-foreground', | |||
| 141 | - | lock: 'text-yellow-500', | |||
| 142 | - | }; | |||
| 143 | - | </script> | |||
| 144 | - | ||||
| 145 | - | <svelte:head> | |||
| 146 | - | <title>{config.title}</title> | |||
| 147 | - | </svelte:head> | |||
| 148 | - | ||||
| 149 | - | <ModeWatcher /> | |||
| 150 | - | <div class="min-h-screen flex items-center justify-center p-8 bg-background"> | |||
| 151 | - | <Card.Root class="w-full max-w-md"> | |||
| 152 | - | <Card.Header class="text-center"> | |||
| 153 | - | <div class="flex justify-center mb-4"> | |||
| 154 | - | <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> | |||
| 155 | - | <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths --> | |||
| 156 | - | {@html icons[config.icon] || icons.error} | |||
| 157 | - | </svg> | |||
| 158 | - | </div> | |||
| 159 | - | {#if config.code > 0} | |||
| 160 | - | <div class="text-6xl font-bold text-muted-foreground/30 mb-2">{config.code}</div> | |||
| 161 | - | {/if} | |||
| 162 | - | <Card.Title class="text-xl">{config.title}</Card.Title> | |||
| 163 | - | <Card.Description>{customMessage || config.message}</Card.Description> | |||
| 164 | - | </Card.Header> | |||
| 165 | - | ||||
| 166 | - | <Card.Content class="space-y-4"> | |||
| 167 | - | {#if targetUrl} | |||
| 168 | - | <div class="bg-muted p-3 rounded-md"> | |||
| 169 | - | <p class="text-xs text-muted-foreground uppercase tracking-wide mb-1">URL</p> | |||
| 170 | - | <code class="text-sm break-all">{targetUrl}</code> | |||
| 171 | - | </div> | |||
| 172 | - | {/if} | |||
| 173 | - | ||||
| 174 | - | {#if actionError} | |||
| 175 | - | <div class="bg-destructive/10 border border-destructive/30 text-destructive p-3 rounded-md text-sm"> | |||
| 176 | - | {actionError} | |||
| 177 | - | </div> | |||
| 178 | - | {/if} | |||
| 179 | - | </Card.Content> | |||
| 180 | - | ||||
| 181 | - | <Card.Footer class="flex flex-col gap-3"> | |||
| 182 | - | {#if config.actions.includes('whitelist') && targetDomain} | |||
| 183 | - | <Button class="w-full" onclick={addToWhitelist} disabled={isLoading}> | |||
| 184 | - | {isLoading ? 'Adding...' : 'Add to Whitelist'} | |||
| 185 | - | </Button> | |||
| 186 | - | {/if} | |||
| 187 | - | ||||
| 188 | - | {#if config.actions.includes('bypass') && targetUrl} | |||
| 189 | - | <Button variant="outline" class="w-full" onclick={bypassOnce} disabled={isLoading}> | |||
| 190 | - | Proceed Anyway | |||
| 191 | - | </Button> | |||
| 192 | - | {/if} | |||
| 193 | - | ||||
| 194 | - | {#if config.actions.includes('retry')} | |||
| 195 | - | <Button class="w-full" onclick={retry} disabled={isLoading}> | |||
| 196 | - | Try Again | |||
| 197 | - | </Button> | |||
| 198 | - | {/if} | |||
| 199 | - | ||||
| 200 | - | {#if config.actions.includes('back')} | |||
| 201 | - | <Button variant="ghost" class="w-full" onclick={goBack} disabled={isLoading}> | |||
| 202 | - | Go Back | |||
| 203 | - | </Button> | |||
| 204 | - | {/if} | |||
| 205 | - | ||||
| 206 | - | {#if config.actions.includes('whitelist') && targetDomain} | |||
| 207 | - | <p class="text-xs text-muted-foreground text-center"> | |||
| 208 | - | Adding <strong class="text-foreground">{targetDomain}</strong> to whitelist will disable content filtering. | |||
| 209 | - | </p> | |||
| 210 | - | {/if} | |||
| 211 | - | </Card.Footer> | |||
| 212 | - | </Card.Root> | |||
| 213 | - | </div> | |||
D webui/src/pages/Homepage.svelte +0 -17
| @@ -1,17 +0,0 @@ | |||||
| 1 | - | <script lang="ts"> | |||
| 2 | - | import { ModeWatcher } from 'mode-watcher'; | |||
| 3 | - | import { HomepageShell } from './homepage/shell'; | |||
| 4 | - | import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels'; | |||
| 5 | - | import { homepageState } from './homepage/state.svelte'; | |||
| 6 | - | </script> | |||
| 7 | - | ||||
| 8 | - | <ModeWatcher /> | |||
| 9 | - | <HomepageShell> | |||
| 10 | - | {#if homepageState.activePanel === 'history'} | |||
| 11 | - | <HistoryPanel /> | |||
| 12 | - | {:else if homepageState.activePanel === 'favorites'} | |||
| 13 | - | <FavoritesPanel /> | |||
| 14 | - | {:else if homepageState.activePanel === 'analytics'} | |||
| 15 | - | <AnalyticsPanel /> | |||
| 16 | - | {/if} | |||
| 17 | - | </HomepageShell> | |||
D webui/src/pages/config.ts +0 -10
| @@ -1,10 +0,0 @@ | |||||
| 1 | - | import { mount } from "svelte"; | |||
| 2 | - | import ConfigPage from "./ConfigPage.svelte"; | |||
| 3 | - | import "../styles/app.css"; | |||
| 4 | - | ||||
| 5 | - | // Mount the config page | |||
| 6 | - | const app = mount(ConfigPage, { | |||
| 7 | - | target: document.body, | |||
| 8 | - | }); | |||
| 9 | - | ||||
| 10 | - | export default app; | |||
D webui/src/pages/config/ConfigShell.svelte +0 -227 collapsed to keep the page light
D webui/src/pages/config/KeyCaptureModal.svelte +0 -184 collapsed to keep the page light
D webui/src/pages/config/KeybindingGroup.svelte +0 -95 collapsed to keep the page light
D webui/src/pages/config/KeybindingsTab.svelte +0 -340 collapsed to keep the page light
D webui/src/pages/config/ShortcutsTable.svelte +0 -170 collapsed to keep the page light
D webui/src/pages/error.ts +0 -11
| @@ -1,11 +0,0 @@ | |||||
| 1 | - | import { mount } from "svelte"; | |||
| 2 | - | import "../styles/app.css"; | |||
| 3 | - | import ErrorPage from "./ErrorPage.svelte"; | |||
| 4 | - | ||||
| 5 | - | console.log("[dumber] Mounting ErrorPage component"); | |||
| 6 | - | try { | |||
| 7 | - | const app = mount(ErrorPage, { target: document.body }); | |||
| 8 | - | console.log("[dumber] ErrorPage mounted successfully", app); | |||
| 9 | - | } catch (error) { | |||
| 10 | - | console.error("[dumber] Failed to mount ErrorPage:", error); | |||
| 11 | - | } | |||
D webui/src/pages/homepage.ts +0 -37 collapsed to keep the page light
D webui/src/pages/homepage/analytics/DailyVisitsChart.svelte +0 -124 collapsed to keep the page light
D webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +0 -130 collapsed to keep the page light
D webui/src/pages/homepage/analytics/index.ts +0 -2
| @@ -1,2 +0,0 @@ | |||||
| 1 | - | export { default as DailyVisitsChart } from './DailyVisitsChart.svelte'; | |||
| 2 | - | export { default as HourlyActivityChart } from './HourlyActivityChart.svelte'; | |||
D webui/src/pages/homepage/favorites/FavoriteCard.svelte +0 -280 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FavoriteEditor.svelte +0 -495 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FavoriteGrid.svelte +0 -108 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FolderList.svelte +0 -312 collapsed to keep the page light
D webui/src/pages/homepage/favorites/TagCloud.svelte +0 -292 collapsed to keep the page light
D webui/src/pages/homepage/favorites/index.ts +0 -6
| @@ -1,6 +0,0 @@ | |||||
| 1 | - | // Favorites Components - Folders, Tags, Grid, Cards, Editor | |||
| 2 | - | export { default as FavoriteCard } from './FavoriteCard.svelte'; | |||
| 3 | - | export { default as FavoriteGrid } from './FavoriteGrid.svelte'; | |||
| 4 | - | export { default as FolderList } from './FolderList.svelte'; | |||
| 5 | - | export { default as TagCloud } from './TagCloud.svelte'; | |||
| 6 | - | export { default as FavoriteEditor } from './FavoriteEditor.svelte'; | |||