From f282261a21e5545ab284a50e2eeab562a84ffe76 Mon Sep 17 00:00:00 2001 From: Clayton Date: Mon, 22 Jun 2026 21:52:51 -0500 Subject: [PATCH 1/2] feat(desktop): multi-KB picker for review console shell --- .github/workflows/desktop.yml | 39 + CHANGELOG.md | 7 + Makefile | 6 +- desktop/README.md | 27 + desktop/app/.gitignore | 4 + desktop/app/README.md | 94 ++ desktop/app/index.html | 38 + desktop/app/package-lock.json | 1424 +++++++++++++++++ desktop/app/package.json | 27 + desktop/app/scripts/generate-icons.py | 43 + desktop/app/src-tauri/Cargo.toml | 29 + desktop/app/src-tauri/build.rs | 3 + .../app/src-tauri/capabilities/default.json | 11 + desktop/app/src-tauri/icons/128x128.png | Bin 0 -> 299 bytes desktop/app/src-tauri/icons/128x128@2x.png | Bin 0 -> 666 bytes desktop/app/src-tauri/icons/32x32.png | Bin 0 -> 104 bytes desktop/app/src-tauri/icons/icon.icns | Bin 0 -> 1820 bytes desktop/app/src-tauri/icons/icon.ico | Bin 0 -> 666 bytes desktop/app/src-tauri/sidecars/vouch.json | 5 + desktop/app/src-tauri/src/kb.rs | 121 ++ desktop/app/src-tauri/src/lib.rs | 191 +++ desktop/app/src-tauri/src/main.rs | 6 + desktop/app/src-tauri/src/menu.rs | 75 + desktop/app/src-tauri/src/sidecar.rs | 173 ++ desktop/app/src-tauri/src/state/mod.rs | 1 + desktop/app/src-tauri/src/state/store.rs | 105 ++ desktop/app/src-tauri/src/util/mod.rs | 1 + desktop/app/src-tauri/src/util/paths.rs | 22 + desktop/app/src-tauri/tauri.conf.json | 37 + desktop/app/src/core/constants.ts | 11 + desktop/app/src/core/errors.ts | 35 + desktop/app/src/core/logger.ts | 31 + desktop/app/src/core/types.ts | 55 + desktop/app/src/ipc/commands.ts | 46 + desktop/app/src/ipc/events.ts | 32 + desktop/app/src/kb/KbValidator.ts | 32 + desktop/app/src/main.ts | 140 ++ desktop/app/src/sidecar/HealthPoller.ts | 46 + desktop/app/src/sidecar/SidecarConfig.ts | 22 + desktop/app/src/sidecar/SidecarVerifier.ts | 18 + desktop/app/src/state/DesktopStateStore.ts | 16 + desktop/app/src/state/RecentKbFormat.ts | 15 + desktop/app/src/state/RecentKbList.ts | 52 + desktop/app/src/state/StateMigrator.ts | 29 + desktop/app/src/styles/error-panel.css | 8 + desktop/app/src/styles/loading.css | 20 + desktop/app/src/styles/shell.css | 106 ++ desktop/app/src/ui/ShellController.ts | 75 + desktop/app/tsconfig.json | 22 + desktop/app/vite.config.ts | 17 + src/vouch/cli.py | 88 +- src/vouch/desktop/__init__.py | 36 + src/vouch/desktop/kb.py | 121 ++ src/vouch/desktop/paths.py | 43 + src/vouch/desktop/ports.py | 25 + src/vouch/desktop/protocol.py | 39 + src/vouch/desktop/sidecar.py | 136 ++ src/vouch/desktop/state.py | 112 ++ src/vouch/web/server.py | 4 + src/vouch/web/templates/base.html | 4 +- tests/test_desktop.py | 149 ++ tests/test_desktop_ports.py | 19 + tests/test_desktop_sidecar.py | 34 + tests/test_web.py | 3 + 64 files changed, 4126 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/desktop.yml create mode 100644 desktop/app/.gitignore create mode 100644 desktop/app/README.md create mode 100644 desktop/app/index.html create mode 100644 desktop/app/package-lock.json create mode 100644 desktop/app/package.json create mode 100644 desktop/app/scripts/generate-icons.py create mode 100644 desktop/app/src-tauri/Cargo.toml create mode 100644 desktop/app/src-tauri/build.rs create mode 100644 desktop/app/src-tauri/capabilities/default.json create mode 100644 desktop/app/src-tauri/icons/128x128.png create mode 100644 desktop/app/src-tauri/icons/128x128@2x.png create mode 100644 desktop/app/src-tauri/icons/32x32.png create mode 100644 desktop/app/src-tauri/icons/icon.icns create mode 100644 desktop/app/src-tauri/icons/icon.ico create mode 100644 desktop/app/src-tauri/sidecars/vouch.json create mode 100644 desktop/app/src-tauri/src/kb.rs create mode 100644 desktop/app/src-tauri/src/lib.rs create mode 100644 desktop/app/src-tauri/src/main.rs create mode 100644 desktop/app/src-tauri/src/menu.rs create mode 100644 desktop/app/src-tauri/src/sidecar.rs create mode 100644 desktop/app/src-tauri/src/state/mod.rs create mode 100644 desktop/app/src-tauri/src/state/store.rs create mode 100644 desktop/app/src-tauri/src/util/mod.rs create mode 100644 desktop/app/src-tauri/src/util/paths.rs create mode 100644 desktop/app/src-tauri/tauri.conf.json create mode 100644 desktop/app/src/core/constants.ts create mode 100644 desktop/app/src/core/errors.ts create mode 100644 desktop/app/src/core/logger.ts create mode 100644 desktop/app/src/core/types.ts create mode 100644 desktop/app/src/ipc/commands.ts create mode 100644 desktop/app/src/ipc/events.ts create mode 100644 desktop/app/src/kb/KbValidator.ts create mode 100644 desktop/app/src/main.ts create mode 100644 desktop/app/src/sidecar/HealthPoller.ts create mode 100644 desktop/app/src/sidecar/SidecarConfig.ts create mode 100644 desktop/app/src/sidecar/SidecarVerifier.ts create mode 100644 desktop/app/src/state/DesktopStateStore.ts create mode 100644 desktop/app/src/state/RecentKbFormat.ts create mode 100644 desktop/app/src/state/RecentKbList.ts create mode 100644 desktop/app/src/state/StateMigrator.ts create mode 100644 desktop/app/src/styles/error-panel.css create mode 100644 desktop/app/src/styles/loading.css create mode 100644 desktop/app/src/styles/shell.css create mode 100644 desktop/app/src/ui/ShellController.ts create mode 100644 desktop/app/tsconfig.json create mode 100644 desktop/app/vite.config.ts create mode 100644 src/vouch/desktop/__init__.py create mode 100644 src/vouch/desktop/kb.py create mode 100644 src/vouch/desktop/paths.py create mode 100644 src/vouch/desktop/ports.py create mode 100644 src/vouch/desktop/protocol.py create mode 100644 src/vouch/desktop/sidecar.py create mode 100644 src/vouch/desktop/state.py create mode 100644 tests/test_desktop.py create mode 100644 tests/test_desktop_ports.py create mode 100644 tests/test_desktop_sidecar.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml new file mode 100644 index 00000000..950221cf --- /dev/null +++ b/.github/workflows/desktop.yml @@ -0,0 +1,39 @@ +name: desktop + +on: + push: + paths: + - "desktop/app/**" + - "src/vouch/desktop/**" + - "tests/test_desktop*.py" + pull_request: + paths: + - "desktop/app/**" + - "src/vouch/desktop/**" + - "tests/test_desktop*.py" + +jobs: + python-desktop: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e '.[dev,web]' + - run: python -m ruff check src/vouch/desktop tests/test_desktop.py tests/test_desktop_ports.py tests/test_desktop_sidecar.py + - run: python -m mypy src/vouch/desktop + - run: python -m pytest tests/test_desktop.py tests/test_desktop_ports.py tests/test_desktop_sidecar.py -q + + desktop-typescript: + runs-on: ubuntu-latest + defaults: + run: + working-directory: desktop/app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm ci + - run: npm run typecheck diff --git a/CHANGELOG.md b/CHANGELOG.md index 8023df2d..6df20cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ All notable changes to vouch are documented here. Format follows as reviewer; a PR opens only when the repo's own test gate is green and the reviewer signs off. A sibling tool — it never writes to the KB or the review gate. Paired with the `auto-pr` skill. +- **desktop multi-KB picker** ([#207](https://github.com/vouchdev/vouch/issues/207)): + Tauri shell under `desktop/app/` with **File → Open KB…**, **Recent KBs** + (last five, persisted in `~/.config/vouch-desktop/state.json`), and **New KB…** + (`vouch init` then open). Switching KBs restarts the `review-ui` sidecar; + window title and masthead show the active project label. Shared Python helpers + in `src/vouch/desktop/` plus `vouch desktop *` CLI commands; `vouch init --json` + for structured init output; `/healthz` and templates expose `kb_label`. - typed page kinds (#234): a KB can declare extra page kinds in `.vouch/config.yaml` under `page_kinds`, each with `required_fields`, a JSON-Schema-subset `frontmatter_schema`, `required_citations`, and one level diff --git a/Makefile b/Makefile index 03dd2596..8cc96772 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev test test-cov lint format type check build clean examples-screenshots +.PHONY: help install dev test test-cov lint format type check build clean examples-screenshots desktop-typecheck PY ?= python PIP ?= $(PY) -m pip @@ -16,6 +16,7 @@ help: @echo " make build build sdist + wheel" @echo " make clean remove caches, build artifacts, *.egg-info" @echo " make examples-screenshots re-render docs/img/examples/*.svg" + @echo " make desktop-typecheck npm typecheck in desktop/app (Tauri shell)" install: $(PIP) install -e '.[dev]' @@ -51,3 +52,6 @@ clean: .pytest_cache .ruff_cache .mypy_cache \ coverage.xml .coverage htmlcov find . -type d -name __pycache__ -prune -exec rm -rf {} + + +desktop-typecheck: + cd desktop/app && npm ci && npm run typecheck diff --git a/desktop/README.md b/desktop/README.md index 08c49cf2..e0afb719 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -139,6 +139,33 @@ v0.1.0 covers the entire `kb.*` surface plus dual-solve. The CLI-only orchestration commands (`auto-pr`, `migrate`, `schema`, `sync-*`) are not yet surfaced — see the [CHANGELOG](./CHANGELOG.md). +## Tauri review-ui shell (`app/`) + +A lighter [Tauri](https://tauri.app/) window around `vouch review-ui` with the +multi-KB picker from [#207](https://github.com/vouchdev/vouch/issues/207). Use +this when you only need the review console (queue / approve / reject) with +**File → Open KB…**, **Recent KBs**, and **New KB…** — not the full Electron +`kb.*` surface above. + +| Path | Purpose | +|------|---------| +| [`app/`](./app/) | Tauri + TypeScript shell (menus, sidecar lifecycle, welcome/error UI) | +| [`app/README.md`](./app/README.md) | Build and dev instructions | +| [`../src/vouch/desktop/`](../src/vouch/desktop/) | Shared Python helpers (state, KB validation, sidecar spawn) | +| [`flatpak/`](./flatpak/) | Linux Flatpak packaging (review-ui only; WIP) | + +```bash +pip install -e '.[web]' +cd desktop/app +npm install +python scripts/generate-icons.py +npm run tauri:dev +``` + +Recent KBs persist in `~/.config/vouch-desktop/state.json` (XDG; +`%APPDATA%\vouch-desktop` on Windows). From the monorepo root: +`make desktop-typecheck`. + ## License MIT. diff --git a/desktop/app/.gitignore b/desktop/app/.gitignore new file mode 100644 index 00000000..d4a64b3f --- /dev/null +++ b/desktop/app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src-tauri/target/ +.DS_Store diff --git a/desktop/app/README.md b/desktop/app/README.md new file mode 100644 index 00000000..ca0cbf3d --- /dev/null +++ b/desktop/app/README.md @@ -0,0 +1,94 @@ +# vouch desktop + +Native shell for the [vouch review console](https://github.com/vouchdev/vouch) with a **multi-KB picker** ([#207](https://github.com/vouchdev/vouch/issues/207)). + +The desktop app wraps the existing Python `vouch review-ui` server in a Tauri window. One window hosts one knowledge base at a time; switching KBs restarts the sidecar against the new project root. + +## Features + +| Menu item | Behaviour | +|-----------|-----------| +| **File → Open KB…** | Folder picker; folder must contain `.vouch/` or you get an inline error with **create KB here** | +| **File → Recent KBs** | Last five opened KBs, persisted in `~/.config/vouch-desktop/state.json` | +| **File → New KB…** | Pick a folder, run `vouch init`, open the new KB | + +The window title updates to `vouch · `. The review UI masthead also shows the active KB label. + +## Prerequisites + +- [Node.js](https://nodejs.org/) 20+ +- [Rust](https://rustup.rs/) stable (for Tauri) +- vouch installed with the `[web]` extra: `pip install -e '.[web]'` + +Ensure `vouch` is on your `PATH` (or configure the Tauri sidecar — see `src-tauri/sidecars/vouch.json`). + +## Development + +```bash +cd desktop/app +npm install +npm run tauri:dev +``` + +`tauri dev` starts the Vite shell on port 1420 for welcome/error screens, then navigates the webview to `http://127.0.0.1:7780/` once a KB is selected. + +## Build + +```bash +cd desktop/app +npm run tauri:build +``` + +Artifacts land under `src-tauri/target/release/bundle/`. + +## State file + +```json +{ + "version": 1, + "last_kb": "/abs/path/to/project", + "recent_kbs": [ + { "path": "/abs/path/a", "label": "a", "opened_at": "2026-06-22T12:00:00+00:00" } + ] +} +``` + +Path follows [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) conventions (`%APPDATA%/vouch-desktop` on Windows). + +## Python CLI helpers + +The shell can invoke these for scripting and tests: + +```bash +vouch desktop config-dir +vouch desktop state-show +vouch desktop state-touch /path/to/project --label my-app +vouch desktop kb-check /path/to/project +vouch desktop kb-init /path/to/new-project +vouch init --path /path --json +``` + +## Architecture + +``` +┌─────────────────────┐ +│ Tauri window │ +│ File menu / title │ +└─────────┬───────────┘ + │ spawn / kill + ▼ +┌─────────────────────┐ +│ vouch review-ui │ ← same review gate as CLI/MCP +│ 127.0.0.1:7780 │ +└─────────┬───────────┘ + │ + ▼ + /.vouch/ +``` + +Shared logic also lives in `src/vouch/desktop/` (state persistence, KB validation, sidecar spawn helpers) and is covered by `tests/test_desktop.py`. + +## Out of scope (v1) + +- Side-by-side multi-KB views in one window +- Cross-KB search / federation diff --git a/desktop/app/index.html b/desktop/app/index.html new file mode 100644 index 00000000..3f3ddea4 --- /dev/null +++ b/desktop/app/index.html @@ -0,0 +1,38 @@ + + + + + + vouch desktop + + + + + +
+
+ +

starting review console…

+
+ + +
+ + + diff --git a/desktop/app/package-lock.json b/desktop/app/package-lock.json new file mode 100644 index 00000000..fc640224 --- /dev/null +++ b/desktop/app/package-lock.json @@ -0,0 +1,1424 @@ +{ + "name": "vouch-desktop", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vouch-desktop", + "version": "0.0.1", + "dependencies": { + "@tauri-apps/api": "^2.2.0", + "@tauri-apps/plugin-dialog": "^2.2.0", + "@tauri-apps/plugin-shell": "^2.2.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.2.0", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.3.tgz", + "integrity": "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.3", + "@tauri-apps/cli-darwin-x64": "2.11.3", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.3", + "@tauri-apps/cli-linux-arm64-musl": "2.11.3", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.3", + "@tauri-apps/cli-linux-x64-gnu": "2.11.3", + "@tauri-apps/cli-linux-x64-musl": "2.11.3", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.3", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.3", + "@tauri-apps/cli-win32-x64-msvc": "2.11.3" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.3.tgz", + "integrity": "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.3.tgz", + "integrity": "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.3.tgz", + "integrity": "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.3.tgz", + "integrity": "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.3.tgz", + "integrity": "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.3.tgz", + "integrity": "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.3.tgz", + "integrity": "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.3.tgz", + "integrity": "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.3.tgz", + "integrity": "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.3.tgz", + "integrity": "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.3.tgz", + "integrity": "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/desktop/app/package.json b/desktop/app/package.json new file mode 100644 index 00000000..3066bc6e --- /dev/null +++ b/desktop/app/package.json @@ -0,0 +1,27 @@ +{ + "name": "vouch-desktop", + "version": "0.0.1", + "private": true, + "description": "Native shell for the vouch review console — multi-KB picker (#207)", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json && vite build", + "preview": "vite preview", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@tauri-apps/api": "^2.2.0", + "@tauri-apps/plugin-dialog": "^2.2.0", + "@tauri-apps/plugin-shell": "^2.2.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.2.0", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/app/scripts/generate-icons.py b/desktop/app/scripts/generate-icons.py new file mode 100644 index 00000000..99955842 --- /dev/null +++ b/desktop/app/scripts/generate-icons.py @@ -0,0 +1,43 @@ +"""Generate minimal placeholder icons for the Tauri bundle.""" + +from __future__ import annotations + +import struct +import zlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "src-tauri" / "icons" + + +def _png_chunk(tag: bytes, data: bytes) -> bytes: + crc = zlib.crc32(tag + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", crc) + + +def write_png(path: Path, size: int, rgba: tuple[int, int, int, int]) -> None: + r, g, b, a = rgba + row = bytes([r, g, b, a]) * size + raw = b"".join([b"\x00" + row for _ in range(size)]) + compressed = zlib.compress(raw, 9) + ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0) + png = b"\x89PNG\r\n\x1a\n" + png += _png_chunk(b"IHDR", ihdr) + png += _png_chunk(b"IDAT", compressed) + png += _png_chunk(b"IEND", b"") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(png) + + +def main() -> None: + color = (168, 44, 28, 255) # vermillion accent + write_png(ROOT / "32x32.png", 32, color) + write_png(ROOT / "128x128.png", 128, color) + write_png(ROOT / "128x128@2x.png", 256, color) + # Tauri accepts png as ico/icns placeholders for dev builds. + write_png(ROOT / "icon.ico", 256, color) + write_png(ROOT / "icon.icns", 512, color) + print(f"wrote icons under {ROOT}") + + +if __name__ == "__main__": + main() diff --git a/desktop/app/src-tauri/Cargo.toml b/desktop/app/src-tauri/Cargo.toml new file mode 100644 index 00000000..5b9b68fa --- /dev/null +++ b/desktop/app/src-tauri/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "vouch-desktop" +version = "0.0.1" +description = "Native shell for the vouch review console" +authors = ["vouch contributors"] +edition = "2021" + +[lib] +name = "vouch_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +dirs = "6" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[profile.release] +panic = "abort" +codegen-units = 1 +lto = true +strip = true diff --git a/desktop/app/src-tauri/build.rs b/desktop/app/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/desktop/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/app/src-tauri/capabilities/default.json b/desktop/app/src-tauri/capabilities/default.json new file mode 100644 index 00000000..414ff334 --- /dev/null +++ b/desktop/app/src-tauri/capabilities/default.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "dev.vouch.desktop", + "windows": ["main"], + "permissions": [ + "core:default", + "dialog:default", + "shell:allow-spawn", + "shell:allow-kill" + ] +} diff --git a/desktop/app/src-tauri/icons/128x128.png b/desktop/app/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..118dbc5cfb7df80acdf69ace732746a077705dd7 GIT binary patch literal 299 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSpFCY0Ln>~)y=2JCz`$|9phbc4 z;SLsQl`k_ImtLH@cz-J=g9Kv(g8&1Q0|N&GqXGlVz;PHDgc%H&D0GB*RIAT%Gpjo3 QJ_aE0boFyt=akR{03Zb`hyVZp literal 0 HcmV?d00001 diff --git a/desktop/app/src-tauri/icons/128x128@2x.png b/desktop/app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1df93a0a1355b4bd993b4544ffe5ca4663ead84b GIT binary patch literal 666 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEyL{AsTkcwMxuNVU5SQs1~ zT?AshxiTKKX@5BE9?th6xPyU>L4qNHp@ETFj0VP`VkQS8_5Dt*P=uR9y@;WOk<(cd XH_I~366vx5ra1;rS3j3^P6EkRnkzk|o5L zj-(vnCbHZKGKr@_fM|cA+AP5^8bkzA#b^)_%s8V#G#W&(;&ikuB3R=P9Yhb#F@*_~ Vum)f6Faa9M;OXk;vd$@?2>>KmoX`LO literal 0 HcmV?d00001 diff --git a/desktop/app/src-tauri/icons/icon.ico b/desktop/app/src-tauri/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1df93a0a1355b4bd993b4544ffe5ca4663ead84b GIT binary patch literal 666 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEyL{AsTkcwMxuNVU5SQs1~ zT?AshxiTKKX@5BE9?th6xPyU>L4qNHp@ETFj0VP`VkQS8_5Dt*P=uR9y@;WOk<(cd XH_I~366vx5ra1;rS3j3^P6, + pub kb_dir: Option, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KbInitResult { + pub ok: bool, + pub project_root: String, + pub kb_dir: String, + pub claim_id: String, + pub starter_present: bool, + pub label: String, +} + +fn resolve_selection(selected: &Path) -> Result { + let resolved = selected + .canonicalize() + .map_err(|e| format!("{selected:?}: {e}"))?; + if resolved.file_name().and_then(|s| s.to_str()) == Some(KB_DIRNAME) { + return Ok(resolved + .parent() + .ok_or_else(|| "invalid .vouch path".to_string())? + .to_path_buf()); + } + Ok(resolved) +} + +pub fn check_kb_folder(selected: &str) -> KbCheckResult { + let path = Path::new(selected); + let root = match resolve_selection(path) { + Ok(r) => r, + Err(message) => { + return KbCheckResult { + ok: false, + project_root: None, + kb_dir: None, + message, + } + } + }; + if !root.is_dir() { + return KbCheckResult { + ok: false, + project_root: Some(root.to_string_lossy().to_string()), + kb_dir: None, + message: format!("{} is not a directory", root.display()), + }; + } + let kb_dir = root.join(KB_DIRNAME); + if kb_dir.is_dir() { + return KbCheckResult { + ok: true, + project_root: Some(root.to_string_lossy().to_string()), + kb_dir: Some(kb_dir.to_string_lossy().to_string()), + message: "ok".into(), + }; + } + KbCheckResult { + ok: false, + project_root: Some(root.to_string_lossy().to_string()), + kb_dir: Some(kb_dir.to_string_lossy().to_string()), + message: format!("no {KB_DIRNAME}/ directory at {}", root.display()), + } +} + +pub async fn run_vouch_json( + app: &tauri::AppHandle, + args: &[&str], +) -> Result { + let shell = app.shell(); + let sidecar = shell + .sidecar("vouch") + .or_else(|_| shell.command("vouch")) + .map_err(|e| e.to_string())?; + let (mut rx, _child) = sidecar + .args(args) + .spawn() + .map_err(|e| e.to_string())?; + + let mut stdout = String::new(); + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line) => stdout.push_str(&line), + CommandEvent::Stderr(line) => stdout.push_str(&line), + CommandEvent::Terminated(payload) => { + if payload.code != Some(0) { + return Err(format!("vouch exited {:?}: {stdout}", payload.code)); + } + break; + } + _ => {} + } + } + serde_json::from_str(stdout.trim()).map_err(|e| format!("invalid json: {e}; raw={stdout}")) +} + +pub async fn init_kb_at(app: &tauri::AppHandle, selected: &str) -> Result { + std::fs::create_dir_all(selected).map_err(|e| e.to_string())?; + let result: KbInitResult = run_vouch_json( + app, + &["desktop", "kb-init", selected], + ) + .await?; + if !result.ok { + return Err("kb-init returned ok=false".into()); + } + Ok(result) +} diff --git a/desktop/app/src-tauri/src/lib.rs b/desktop/app/src-tauri/src/lib.rs new file mode 100644 index 00000000..3a9b53f7 --- /dev/null +++ b/desktop/app/src-tauri/src/lib.rs @@ -0,0 +1,191 @@ +//! vouch desktop — native shell for the review console (#207). + +mod kb; +mod menu; +mod sidecar; +mod state; +mod util; + +use kb::{check_kb_folder, init_kb_at, KbCheckResult, KbInitResult}; +use menu::{build_menu, handle_menu_event, rebuild_recent_submenu}; +use sidecar::SidecarManager; +use state::store::{load_state, touch_recent_kb, DesktopState}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tauri_plugin_dialog::DialogExt; + +struct AppState { + sidecar: SidecarManager, +} + +#[derive(serde::Serialize)] +struct SwitchKbResult { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kb_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[tauri::command] +fn load_state_cmd() -> DesktopState { + load_state(None) +} + +#[tauri::command] +fn touch_recent_kb_cmd(project_root: String, label: Option) -> Result { + touch_recent_kb(&project_root, label.as_deref(), None) +} + +#[tauri::command] +fn check_kb_folder_cmd(selected: String) -> KbCheckResult { + check_kb_folder(&selected) +} + +#[tauri::command] +async fn init_kb_at_cmd(app: AppHandle, selected: String) -> Result { + init_kb_at(&app, &selected).await +} + +#[tauri::command] +async fn switch_kb( + app: AppHandle, + state: State<'_, AppState>, + project_root: String, +) -> Result { + let check = check_kb_folder(&project_root); + if !check.ok { + let message = check.message.clone(); + let _ = app.emit( + "kb-error", + serde_json::json!({ "selected": project_root, "message": message }), + ); + return Ok(SwitchKbResult { + ok: false, + base_url: None, + kb_label: None, + error: Some(message), + }); + } + let root = check + .project_root + .clone() + .ok_or_else(|| "missing project_root".to_string())?; + + let (base_url, kb_label) = state.sidecar.start(&app, &root).await?; + touch_recent_kb(&root, Some(&kb_label), None)?; + let _ = rebuild_recent_submenu(&app); + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_title(&format!("vouch · {kb_label}")); + let _ = window.navigate(tauri::Url::parse(&base_url).map_err(|e| e.to_string())?); + } + + let payload = serde_json::json!({ + "project_root": root, + "kb_label": kb_label, + "base_url": base_url, + }); + let _ = app.emit("kb-switched", payload); + + Ok(SwitchKbResult { + ok: true, + base_url: Some(base_url), + kb_label: Some(kb_label), + error: None, + }) +} + +#[tauri::command] +async fn open_recent_kb( + app: AppHandle, + state: State<'_, AppState>, + path: String, +) -> Result { + switch_kb(app, state, path).await +} + +#[tauri::command] +fn sidecar_status(state: State<'_, AppState>) -> sidecar::SidecarStatus { + state.sidecar.status() +} + +#[tauri::command] +async fn open_kb_dialog(app: AppHandle) -> Result, String> { + let picked = app + .dialog() + .file() + .set_title("Open knowledge base") + .blocking_pick_folder(); + Ok(picked.map(|p| p.to_string())) +} + +#[tauri::command] +async fn new_kb_dialog(app: AppHandle) -> Result, String> { + let picked = app + .dialog() + .file() + .set_title("New knowledge base location") + .blocking_pick_folder(); + Ok(picked.map(|p| p.to_string())) +} + +#[tauri::command] +fn navigate_to_review(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { + let base = state + .sidecar + .status() + .base_url + .ok_or_else(|| "sidecar not running".to_string())?; + if let Some(window) = app.get_webview_window("main") { + window + .navigate(tauri::Url::parse(&base).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +fn show_welcome(app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("main") { + window + .navigate(tauri::Url::parse("http://localhost:1420").map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + window.set_title("vouch").map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .manage(AppState { + sidecar: SidecarManager::default(), + }) + .setup(|app| { + let menu = build_menu(app.handle())?; + app.set_menu(menu)?; + Ok(()) + }) + .on_menu_event(|app, event| { + handle_menu_event(app, event.id().as_ref()); + }) + .invoke_handler(tauri::generate_handler![ + load_state_cmd, + touch_recent_kb_cmd, + check_kb_folder_cmd, + init_kb_at_cmd, + switch_kb, + open_recent_kb, + sidecar_status, + open_kb_dialog, + new_kb_dialog, + navigate_to_review, + show_welcome, + ]) + .run(tauri::generate_context!()) + .expect("error while running vouch desktop"); +} diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs new file mode 100644 index 00000000..a2e2cb66 --- /dev/null +++ b/desktop/app/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + vouch_desktop_lib::run(); +} diff --git a/desktop/app/src-tauri/src/menu.rs b/desktop/app/src-tauri/src/menu.rs new file mode 100644 index 00000000..c79f1346 --- /dev/null +++ b/desktop/app/src-tauri/src/menu.rs @@ -0,0 +1,75 @@ +//! Application menu: File → Open KB / Recent KBs / New KB (#207). + +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; +use tauri::{AppHandle, Emitter, Manager, Wry}; + +use crate::state::store::{load_state, label_for_path}; + +pub const MENU_OPEN_KB: &str = "open-kb"; +pub const MENU_NEW_KB: &str = "new-kb"; +pub const MENU_RECENT_PREFIX: &str = "recent-kb-"; + +pub fn build_menu(app: &AppHandle) -> tauri::Result> { + let open_kb = MenuItem::with_id(app, MENU_OPEN_KB, "Open KB…", true, None::<&str>)?; + let new_kb = MenuItem::with_id(app, MENU_NEW_KB, "New KB…", true, None::<&str>)?; + let recent_sub = build_recent_submenu(app)?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = PredefinedMenuItem::quit(app, Some("Quit"))?; + + let file_menu = Submenu::with_items( + app, + "File", + true, + &[&open_kb, &recent_sub, &separator, &new_kb, &separator, &quit], + )?; + + Menu::with_items(app, &[&file_menu]) +} + +pub fn rebuild_recent_submenu(app: &AppHandle) -> tauri::Result<()> { + let menu = build_menu(app)?; + app.set_menu(menu)?; + Ok(()) +} + +fn build_recent_submenu(app: &AppHandle) -> tauri::Result> { + let state = load_state(None); + let mut items: Vec> = Vec::new(); + if state.recent_kbs.is_empty() { + let empty = MenuItem::with_id(app, "recent-empty", "(no recent KBs)", false, None::<&str>)?; + items.push(empty); + } else { + for (idx, entry) in state.recent_kbs.iter().enumerate() { + let id = format!("{MENU_RECENT_PREFIX}{idx}"); + let label = if entry.label.is_empty() { + label_for_path(&entry.path) + } else { + entry.label.clone() + }; + let item = MenuItem::with_id(app, &id, label, true, None::<&str>)?; + items.push(item); + } + } + let refs: Vec<&dyn tauri::menu::IsMenuItem> = + items.iter().map(|i| i as &dyn tauri::menu::IsMenuItem).collect(); + Submenu::with_items(app, "Recent KBs", true, &refs) +} + +pub fn handle_menu_event(app: &AppHandle, event_id: &str) { + if event_id == MENU_OPEN_KB { + let _ = app.emit("menu-open-kb", ()); + return; + } + if event_id == MENU_NEW_KB { + let _ = app.emit("menu-new-kb", ()); + return; + } + if let Some(idx_str) = event_id.strip_prefix(MENU_RECENT_PREFIX) { + if let Ok(idx) = idx_str.parse::() { + let state = load_state(None); + if let Some(entry) = state.recent_kbs.get(idx) { + let _ = app.emit("menu-recent-kb", entry.path.clone()); + } + } + } +} diff --git a/desktop/app/src-tauri/src/sidecar.rs b/desktop/app/src-tauri/src/sidecar.rs new file mode 100644 index 00000000..d787b7b5 --- /dev/null +++ b/desktop/app/src-tauri/src/sidecar.rs @@ -0,0 +1,173 @@ +//! Review-ui sidecar process management. + +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::AppHandle; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; + +pub const DEFAULT_HOST: &str = "127.0.0.1"; +pub const DEFAULT_PORT: u16 = 7780; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +const POLL_INTERVAL: Duration = Duration::from_millis(150); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthzResponse { + pub ok: bool, + pub kb: String, + pub kb_label: Option, + pub pending: i64, + pub auth: bool, + pub clients: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidecarStatus { + pub running: bool, + pub base_url: Option, + pub project_root: Option, + pub kb_label: Option, + pub pid: Option, +} + +pub struct SidecarManager { + child: Mutex>, + base_url: Mutex>, + project_root: Mutex>, + kb_label: Mutex>, + port: Mutex, +} + +impl Default for SidecarManager { + fn default() -> Self { + Self { + child: Mutex::new(None), + base_url: Mutex::new(None), + project_root: Mutex::new(None), + kb_label: Mutex::new(None), + port: Mutex::new(DEFAULT_PORT), + } + } +} + +impl SidecarManager { + pub fn status(&self) -> SidecarStatus { + SidecarStatus { + running: self.child.lock().ok().and_then(|g| g.as_ref().map(|_| true)).unwrap_or(false), + base_url: self.base_url.lock().ok().and_then(|g| g.clone()), + project_root: self.project_root.lock().ok().and_then(|g| g.clone()), + kb_label: self.kb_label.lock().ok().and_then(|g| g.clone()), + pid: None, + } + } + + pub async fn stop(&self) -> Result<(), String> { + let mut guard = self.child.lock().map_err(|e| e.to_string())?; + if let Some(child) = guard.take() { + child.kill().map_err(|e| e.to_string())?; + } + if let Ok(mut url) = self.base_url.lock() { + *url = None; + } + Ok(()) + } + + fn health_url(base: &str) -> String { + format!("{base}/healthz") + } + + fn wait_for_health(base: &str, expected_root: &str) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .map_err(|e| e.to_string())?; + let deadline = Instant::now() + STARTUP_TIMEOUT; + let mut last_err = "sidecar did not become healthy".to_string(); + while Instant::now() < deadline { + match client.get(Self::health_url(base)).send() { + Ok(resp) => { + if let Ok(body) = resp.json::() { + if body.ok { + let kb = Path::new(&body.kb); + let expected = Path::new(expected_root); + if kb.canonicalize().ok() == expected.canonicalize().ok() { + return Ok(body); + } + last_err = format!("kb mismatch: {} != {}", body.kb, expected_root); + } + } + } + Err(e) => last_err = e.to_string(), + } + std::thread::sleep(POLL_INTERVAL); + } + Err(last_err) + } + + pub async fn start(&self, app: &AppHandle, project_root: &str) -> Result<(String, String), String> { + self.stop().await?; + + let port = { + let mut guard = self.port.lock().map_err(|e| e.to_string())?; + *guard = DEFAULT_PORT; + *guard + }; + let bind = format!("{DEFAULT_HOST}:{port}"); + let base_url = format!("http://{DEFAULT_HOST}:{port}"); + + let shell = app.shell(); + let sidecar = shell + .sidecar("vouch") + .or_else(|_| shell.command("vouch")) + .map_err(|e| e.to_string())?; + + let (mut rx, child) = sidecar + .args([ + "review-ui", + "--bind", + &bind, + "--kb", + project_root, + "--no-open-browser", + "--reviewer", + "desktop-reviewer", + ]) + .spawn() + .map_err(|e| e.to_string())?; + + { + let mut guard = self.child.lock().map_err(|e| e.to_string())?; + *guard = Some(child); + } + + // Drain events in background so the pipe doesn't block the child. + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + if matches!(event, CommandEvent::Terminated(_)) { + break; + } + } + }); + + let health = Self::wait_for_health(&base_url, project_root)?; + let label = health + .kb_label + .clone() + .unwrap_or_else(|| Path::new(project_root).file_name().unwrap().to_string_lossy().to_string()); + + if let Ok(mut url) = self.base_url.lock() { + *url = Some(base_url.clone()); + } + if let Ok(mut root) = self.project_root.lock() { + *root = Some(project_root.to_string()); + } + if let Ok(mut lbl) = self.kb_label.lock() { + *lbl = Some(label.clone()); + } + + Ok((base_url, label)) + } +} diff --git a/desktop/app/src-tauri/src/state/mod.rs b/desktop/app/src-tauri/src/state/mod.rs new file mode 100644 index 00000000..55c88cbf --- /dev/null +++ b/desktop/app/src-tauri/src/state/mod.rs @@ -0,0 +1 @@ +pub mod store; diff --git a/desktop/app/src-tauri/src/state/store.rs b/desktop/app/src-tauri/src/state/store.rs new file mode 100644 index 00000000..14cf003a --- /dev/null +++ b/desktop/app/src-tauri/src/state/store.rs @@ -0,0 +1,105 @@ +//! Recent-KB persistence (`~/.config/vouch-desktop/state.json`). + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::util::paths::{ensure_config_dir, state_file_path}; + +pub const STATE_VERSION: i32 = 1; +pub const MAX_RECENT: usize = 5; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentKbEntry { + pub path: String, + pub label: String, + pub opened_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopState { + pub version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_kb: Option, + pub recent_kbs: Vec, +} + +impl Default for DesktopState { + fn default() -> Self { + Self { + version: STATE_VERSION, + last_kb: None, + recent_kbs: Vec::new(), + } + } +} + +pub fn load_state(path: Option<&Path>) -> DesktopState { + let target = path.map(PathBuf::from).unwrap_or_else(state_file_path); + if !target.is_file() { + return DesktopState::default(); + } + let text = match std::fs::read_to_string(&target) { + Ok(t) => t, + Err(_) => return DesktopState::default(), + }; + serde_json::from_str(&text).unwrap_or_default() +} + +pub fn save_state(state: &DesktopState, path: Option<&Path>) -> Result { + let target = path.map(PathBuf::from).unwrap_or_else(state_file_path); + ensure_config_dir().map_err(|e| e.to_string())?; + let tmp = target.with_extension("tmp"); + let payload = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; + std::fs::write(&tmp, format!("{payload}\n")).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &target).map_err(|e| e.to_string())?; + Ok(target) +} + +pub fn label_for_path(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(path) + .to_string() +} + +pub fn touch_recent_kb( + project_root: &str, + label: Option<&str>, + state: Option, +) -> Result { + let root = Path::new(project_root) + .canonicalize() + .map_err(|e| e.to_string())? + .to_string_lossy() + .to_string(); + let entry_label = label + .map(str::to_string) + .unwrap_or_else(|| label_for_path(&root)); + let mut base = state.unwrap_or_else(|| load_state(None)); + base.recent_kbs + .retain(|e| !paths_equal(&e.path, &root)); + base.recent_kbs.insert( + 0, + RecentKbEntry { + path: root.clone(), + label: entry_label, + opened_at: Utc::now().to_rfc3339(), + }, + ); + base.recent_kbs.truncate(MAX_RECENT); + base.last_kb = Some(root); + base.version = STATE_VERSION; + save_state(&base, None)?; + Ok(base) +} + +fn paths_equal(a: &str, b: &str) -> bool { + Path::new(a) + .canonicalize() + .ok() + .zip(Path::new(b).canonicalize().ok()) + .map(|(x, y)| x == y) + .unwrap_or_else(|| a.eq_ignore_ascii_case(b)) +} diff --git a/desktop/app/src-tauri/src/util/mod.rs b/desktop/app/src-tauri/src/util/mod.rs new file mode 100644 index 00000000..8118b296 --- /dev/null +++ b/desktop/app/src-tauri/src/util/mod.rs @@ -0,0 +1 @@ +pub mod paths; diff --git a/desktop/app/src-tauri/src/util/paths.rs b/desktop/app/src-tauri/src/util/paths.rs new file mode 100644 index 00000000..0f36f79c --- /dev/null +++ b/desktop/app/src-tauri/src/util/paths.rs @@ -0,0 +1,22 @@ +//! XDG config paths for the desktop shell. + +use std::path::PathBuf; + +pub const APP_DIRNAME: &str = "vouch-desktop"; +pub const STATE_FILENAME: &str = "state.json"; + +pub fn config_dir() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".config")) + .join(APP_DIRNAME) +} + +pub fn state_file_path() -> PathBuf { + config_dir().join(STATE_FILENAME) +} + +pub fn ensure_config_dir() -> std::io::Result { + let dir = config_dir(); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} diff --git a/desktop/app/src-tauri/tauri.conf.json b/desktop/app/src-tauri/tauri.conf.json new file mode 100644 index 00000000..b79d2bea --- /dev/null +++ b/desktop/app/src-tauri/tauri.conf.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "vouch", + "version": "0.0.1", + "identifier": "dev.vouch.desktop", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "vouch", + "width": 1280, + "height": 900, + "resizable": true + } + ], + "security": { + "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:* http://localhost:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "plugins": {} +} diff --git a/desktop/app/src/core/constants.ts b/desktop/app/src/core/constants.ts new file mode 100644 index 00000000..e52dfcb6 --- /dev/null +++ b/desktop/app/src/core/constants.ts @@ -0,0 +1,11 @@ +/** Shared constants for the vouch desktop shell (#207). */ + +export const APP_NAME = "vouch"; +export const STATE_VERSION = 1; +export const MAX_RECENT_KBS = 5; +export const DEFAULT_BIND_HOST = "127.0.0.1"; +export const DEFAULT_BIND_PORT = 7780; +export const HEALTH_POLL_INTERVAL_MS = 150; +export const SIDECAR_STARTUP_TIMEOUT_MS = 30_000; +export const SIDECAR_TERMINATE_TIMEOUT_MS = 5_000; +export const WINDOW_TITLE_PREFIX = "vouch · "; diff --git a/desktop/app/src/core/errors.ts b/desktop/app/src/core/errors.ts new file mode 100644 index 00000000..7f0a947c --- /dev/null +++ b/desktop/app/src/core/errors.ts @@ -0,0 +1,35 @@ +/** Typed errors for the desktop shell. */ + +export class DesktopError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "DesktopError"; + this.code = code; + } +} + +export class SidecarStartupError extends DesktopError { + constructor(message: string) { + super("sidecar_startup", message); + this.name = "SidecarStartupError"; + } +} + +export class KbValidationError extends DesktopError { + readonly selectedPath: string; + + constructor(selectedPath: string, message: string) { + super("kb_validation", message); + this.name = "KbValidationError"; + this.selectedPath = selectedPath; + } +} + +export class StatePersistenceError extends DesktopError { + constructor(message: string) { + super("state_persistence", message); + this.name = "StatePersistenceError"; + } +} diff --git a/desktop/app/src/core/logger.ts b/desktop/app/src/core/logger.ts new file mode 100644 index 00000000..c1ce0970 --- /dev/null +++ b/desktop/app/src/core/logger.ts @@ -0,0 +1,31 @@ +/** Minimal namespaced logger for shell diagnostics. */ + +type Level = "debug" | "info" | "warn" | "error"; + +function emit(level: Level, scope: string, message: string, extra?: unknown): void { + const prefix = `[vouch-desktop:${scope}]`; + const payload = extra === undefined ? message : `${message} ${JSON.stringify(extra)}`; + switch (level) { + case "debug": + console.debug(prefix, payload); + break; + case "info": + console.info(prefix, payload); + break; + case "warn": + console.warn(prefix, payload); + break; + case "error": + console.error(prefix, payload); + break; + } +} + +export function createLogger(scope: string) { + return { + debug: (message: string, extra?: unknown) => emit("debug", scope, message, extra), + info: (message: string, extra?: unknown) => emit("info", scope, message, extra), + warn: (message: string, extra?: unknown) => emit("warn", scope, message, extra), + error: (message: string, extra?: unknown) => emit("error", scope, message, extra), + }; +} diff --git a/desktop/app/src/core/types.ts b/desktop/app/src/core/types.ts new file mode 100644 index 00000000..c9eb1537 --- /dev/null +++ b/desktop/app/src/core/types.ts @@ -0,0 +1,55 @@ +/** Core type definitions for the desktop shell. */ + +export interface RecentKbEntry { + path: string; + label: string; + opened_at: string; +} + +export interface DesktopState { + version: number; + last_kb: string | null; + recent_kbs: RecentKbEntry[]; +} + +export interface KbCheckResult { + ok: boolean; + project_root: string | null; + kb_dir: string | null; + message: string; +} + +export interface KbInitResult { + ok: boolean; + project_root: string; + kb_dir: string; + claim_id: string; + starter_present: boolean; + label: string; +} + +export interface HealthzResponse { + ok: boolean; + kb: string; + kb_label?: string; + pending: number; + auth: boolean; + clients: number; +} + +export interface SidecarStatus { + running: boolean; + base_url: string | null; + project_root: string | null; + kb_label: string | null; + pid: number | null; +} + +export type ShellView = "loading" | "welcome" | "error" | "review"; + +export interface SwitchKbResult { + ok: boolean; + base_url?: string; + kb_label?: string; + error?: string; +} diff --git a/desktop/app/src/ipc/commands.ts b/desktop/app/src/ipc/commands.ts new file mode 100644 index 00000000..ab907a41 --- /dev/null +++ b/desktop/app/src/ipc/commands.ts @@ -0,0 +1,46 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { DesktopState, KbCheckResult, KbInitResult, SidecarStatus, SwitchKbResult } from "../core/types"; + +export async function loadDesktopState(): Promise { + return invoke("load_state"); +} + +export async function touchRecentKb(projectRoot: string, label?: string): Promise { + return invoke("touch_recent_kb", { projectRoot, label: label ?? null }); +} + +export async function checkKbFolder(selected: string): Promise { + return invoke("check_kb_folder", { selected }); +} + +export async function initKbAt(selected: string): Promise { + return invoke("init_kb_at", { selected }); +} + +export async function switchKb(projectRoot: string): Promise { + return invoke("switch_kb", { projectRoot }); +} + +export async function openKbDialog(): Promise { + return invoke("open_kb_dialog"); +} + +export async function newKbDialog(): Promise { + return invoke("new_kb_dialog"); +} + +export async function openRecentKb(path: string): Promise { + return invoke("open_recent_kb", { path }); +} + +export async function getSidecarStatus(): Promise { + return invoke("sidecar_status"); +} + +export async function navigateToReview(): Promise { + return invoke("navigate_to_review"); +} + +export async function showWelcome(): Promise { + return invoke("show_welcome"); +} diff --git a/desktop/app/src/ipc/events.ts b/desktop/app/src/ipc/events.ts new file mode 100644 index 00000000..8d2f52ac --- /dev/null +++ b/desktop/app/src/ipc/events.ts @@ -0,0 +1,32 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +export interface KbSwitchedPayload { + project_root: string; + kb_label: string; + base_url: string; +} + +export interface KbErrorPayload { + selected: string; + message: string; +} + +export async function onKbSwitched(handler: (payload: KbSwitchedPayload) => void): Promise { + return listen("kb-switched", (event) => handler(event.payload)); +} + +export async function onKbError(handler: (payload: KbErrorPayload) => void): Promise { + return listen("kb-error", (event) => handler(event.payload)); +} + +export async function onMenuOpenKb(handler: () => void): Promise { + return listen("menu-open-kb", () => handler()); +} + +export async function onMenuNewKb(handler: () => void): Promise { + return listen("menu-new-kb", () => handler()); +} + +export async function onMenuRecentKb(handler: (path: string) => void): Promise { + return listen("menu-recent-kb", (event) => handler(event.payload)); +} diff --git a/desktop/app/src/kb/KbValidator.ts b/desktop/app/src/kb/KbValidator.ts new file mode 100644 index 00000000..8aa8cae8 --- /dev/null +++ b/desktop/app/src/kb/KbValidator.ts @@ -0,0 +1,32 @@ +/** Client-side KB folder validation (mirrors Rust/Python checks). */ + +import type { KbCheckResult } from "../core/types"; + +const KB_DIRNAME = ".vouch"; + +export function labelForPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +export function resolveSelection(selected: string): string { + const normalized = selected.replace(/\\/g, "/").replace(/\/+$/, ""); + if (normalized.endsWith(`/${KB_DIRNAME}`) || normalized.endsWith(KB_DIRNAME)) { + const idx = normalized.lastIndexOf(`/${KB_DIRNAME}`); + if (idx >= 0) return normalized.slice(0, idx) || normalized; + } + return normalized; +} + +export function formatKbCheckMessage(result: KbCheckResult): string { + if (result.ok) return "Knowledge base is ready."; + if (result.message.includes(KB_DIRNAME)) { + return `This folder does not contain a ${KB_DIRNAME}/ directory. You can create one here.`; + } + return result.message; +} + +export function isLikelyProjectRoot(path: string): boolean { + return path.length > 0 && !path.includes("\0"); +} diff --git a/desktop/app/src/main.ts b/desktop/app/src/main.ts new file mode 100644 index 00000000..e05eaa67 --- /dev/null +++ b/desktop/app/src/main.ts @@ -0,0 +1,140 @@ +import { createLogger } from "./core/logger"; +import { + checkKbFolder, + initKbAt, + loadDesktopState, + newKbDialog, + openKbDialog, + openRecentKb, + switchKb, +} from "./ipc/commands"; +import { + onKbError, + onKbSwitched, + onMenuNewKb, + onMenuOpenKb, + onMenuRecentKb, +} from "./ipc/events"; +import type { KbSwitchedPayload, KbErrorPayload } from "./ipc/events"; +import { ShellController } from "./ui/ShellController"; + +const log = createLogger("main"); + +let pendingErrorPath: string | null = null; + +async function trySwitch(projectRoot: string): Promise { + const shell = getShell(); + shell.setView("loading"); + shell.setLoadingMessage(`opening ${projectRoot}…`); + const result = await switchKb(projectRoot); + if (!result.ok) { + shell.showError(projectRoot, result.error ?? "failed to switch knowledge base"); + pendingErrorPath = projectRoot; + return; + } + log.info("switched KB", result); +} + +async function handleOpenDialog(): Promise { + const selected = await openKbDialog(); + if (!selected) return; + const check = await checkKbFolder(selected); + const shell = getShell(); + if (!check.ok) { + shell.showError(selected, check.message); + pendingErrorPath = selected; + return; + } + if (!check.project_root) return; + await trySwitch(check.project_root); +} + +async function handleNewDialog(): Promise { + const selected = await newKbDialog(); + if (!selected) return; + const shell = getShell(); + shell.setView("loading"); + shell.setLoadingMessage("initialising knowledge base…"); + try { + const init = await initKbAt(selected); + if (!init.ok || !init.starter_present) { + shell.showError(selected, "init did not produce a starter claim"); + pendingErrorPath = selected; + return; + } + await trySwitch(init.project_root); + } catch (err) { + shell.showError(selected, String(err)); + pendingErrorPath = selected; + } +} + +async function handleCreateKbHere(): Promise { + if (!pendingErrorPath) return; + const shell = getShell(); + shell.setView("loading"); + shell.setLoadingMessage("creating knowledge base…"); + const init = await initKbAt(pendingErrorPath); + if (!init.ok) { + shell.showError(pendingErrorPath, "failed to create knowledge base"); + return; + } + await trySwitch(init.project_root); +} + +async function bootstrap(): Promise { + const shell = getShell(); + shell.setView("loading"); + shell.setLoadingMessage("loading desktop state…"); + + const state = await loadDesktopState(); + shell.renderRecent(state.recent_kbs, (path: string) => { + void openRecentKb(path).then((result) => { + if (!result.ok) shell.showError(path, result.error ?? "failed to open recent KB"); + }); + }); + + if (state.last_kb) { + shell.setLoadingMessage("restoring last knowledge base…"); + const result = await switchKb(state.last_kb); + if (result.ok) return; + log.warn("last KB failed", result); + } + + shell.setView("welcome"); +} + +let shellSingleton: ShellController | null = null; + +function getShell(): ShellController { + if (!shellSingleton) shellSingleton = new ShellController(); + return shellSingleton; +} + +function wireUi(): void { + const shell = getShell(); + shell.bindActions({ + onOpenKb: () => void handleOpenDialog(), + onNewKb: () => void handleNewDialog(), + onCreateKb: () => void handleCreateKbHere(), + onPickFolder: () => void handleOpenDialog(), + }); +} + +function wireEvents(): void { + void onKbSwitched((payload: KbSwitchedPayload) => { + log.info("kb switched event", payload); + document.title = `vouch · ${payload.kb_label}`; + }); + void onKbError((payload: KbErrorPayload) => { + getShell().showError(payload.selected, payload.message); + pendingErrorPath = payload.selected; + }); + void onMenuOpenKb(() => void handleOpenDialog()); + void onMenuNewKb(() => void handleNewDialog()); + void onMenuRecentKb((path: string) => void trySwitch(path)); +} + +wireUi(); +wireEvents(); +void bootstrap(); diff --git a/desktop/app/src/sidecar/HealthPoller.ts b/desktop/app/src/sidecar/HealthPoller.ts new file mode 100644 index 00000000..274a99f5 --- /dev/null +++ b/desktop/app/src/sidecar/HealthPoller.ts @@ -0,0 +1,46 @@ +import type { HealthzResponse } from "../core/types"; +import { HEALTH_POLL_INTERVAL_MS, SIDECAR_STARTUP_TIMEOUT_MS } from "../core/constants"; +import { createLogger } from "../core/logger"; + +const log = createLogger("health"); + +export async function pollHealthz( + baseUrl: string, + expectedRoot?: string, + timeoutMs: number = SIDECAR_STARTUP_TIMEOUT_MS, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = "sidecar did not become healthy"; + const healthUrl = `${baseUrl.replace(/\/$/, "")}/healthz`; + + while (Date.now() < deadline) { + try { + const resp = await fetch(healthUrl, { method: "GET" }); + if (!resp.ok) { + lastError = `healthz HTTP ${resp.status}`; + } else { + const body = (await resp.json()) as HealthzResponse; + if (!body.ok) { + lastError = "healthz ok=false"; + } else if (expectedRoot && normalize(body.kb) !== normalize(expectedRoot)) { + lastError = `kb mismatch: ${body.kb} != ${expectedRoot}`; + } else { + return body; + } + } + } catch (err) { + lastError = String(err); + } + await sleep(HEALTH_POLL_INTERVAL_MS); + } + log.error("health poll timed out", { lastError, healthUrl }); + throw new Error(lastError); +} + +function normalize(path: string): string { + return path.replace(/\\/g, "/").toLowerCase(); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/desktop/app/src/sidecar/SidecarConfig.ts b/desktop/app/src/sidecar/SidecarConfig.ts new file mode 100644 index 00000000..49f5f5ab --- /dev/null +++ b/desktop/app/src/sidecar/SidecarConfig.ts @@ -0,0 +1,22 @@ +import { DEFAULT_BIND_HOST, DEFAULT_BIND_PORT } from "../core/constants"; + +export function defaultBaseUrl(): string { + return `http://${DEFAULT_BIND_HOST}:${DEFAULT_BIND_PORT}`; +} + +export function reviewUiArgs(projectRoot: string, port: number = DEFAULT_BIND_PORT): string[] { + return [ + "review-ui", + "--bind", + `${DEFAULT_BIND_HOST}:${port}`, + "--kb", + projectRoot, + "--no-open-browser", + "--reviewer", + "desktop-reviewer", + ]; +} + +export function windowTitleForLabel(label: string): string { + return `vouch · ${label}`; +} diff --git a/desktop/app/src/sidecar/SidecarVerifier.ts b/desktop/app/src/sidecar/SidecarVerifier.ts new file mode 100644 index 00000000..d8dcd47a --- /dev/null +++ b/desktop/app/src/sidecar/SidecarVerifier.ts @@ -0,0 +1,18 @@ +import { createLogger } from "../core/logger"; +import { pollHealthz } from "../sidecar/HealthPoller"; +import { defaultBaseUrl } from "../sidecar/SidecarConfig"; + +const log = createLogger("sidecar-verify"); + +export async function verifySidecarReady( + baseUrl: string = defaultBaseUrl(), + expectedRoot?: string, +): Promise { + try { + await pollHealthz(baseUrl, expectedRoot, 5_000); + return true; + } catch (err) { + log.warn("sidecar not ready", err); + return false; + } +} diff --git a/desktop/app/src/state/DesktopStateStore.ts b/desktop/app/src/state/DesktopStateStore.ts new file mode 100644 index 00000000..4be249b8 --- /dev/null +++ b/desktop/app/src/state/DesktopStateStore.ts @@ -0,0 +1,16 @@ +/** Desktop state schema helpers. */ + +import type { DesktopState } from "../core/types"; +import { migrateState } from "../state/StateMigrator"; + +export function parseStateJson(text: string): DesktopState { + try { + return migrateState(JSON.parse(text) as unknown); + } catch { + return migrateState(null); + } +} + +export function serializeState(state: DesktopState): string { + return JSON.stringify(state, null, 2) + "\n"; +} diff --git a/desktop/app/src/state/RecentKbFormat.ts b/desktop/app/src/state/RecentKbFormat.ts new file mode 100644 index 00000000..2b8fff68 --- /dev/null +++ b/desktop/app/src/state/RecentKbFormat.ts @@ -0,0 +1,15 @@ +import { labelForPath } from "../kb/KbValidator"; +import type { RecentKbEntry } from "../core/types"; + +export function formatRecentMenuLabel(entry: RecentKbEntry): string { + if (entry.label.trim().length > 0) return entry.label; + return labelForPath(entry.path); +} + +export function sortRecentByOpenedAt(entries: RecentKbEntry[]): RecentKbEntry[] { + return [...entries].sort((a, b) => b.opened_at.localeCompare(a.opened_at)); +} + +export function truncateRecent(entries: RecentKbEntry[], max: number): RecentKbEntry[] { + return entries.slice(0, max); +} diff --git a/desktop/app/src/state/RecentKbList.ts b/desktop/app/src/state/RecentKbList.ts new file mode 100644 index 00000000..1fde9198 --- /dev/null +++ b/desktop/app/src/state/RecentKbList.ts @@ -0,0 +1,52 @@ +import type { DesktopState, RecentKbEntry } from "../core/types"; +import { MAX_RECENT_KBS, STATE_VERSION } from "../core/constants"; + +export function emptyState(): DesktopState { + return { + version: STATE_VERSION, + last_kb: null, + recent_kbs: [], + }; +} + +export function normalizePath(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +export function dedupeRecent(entries: RecentKbEntry[]): RecentKbEntry[] { + const seen = new Set(); + const out: RecentKbEntry[] = []; + for (const entry of entries) { + const key = normalizePath(entry.path).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(entry); + } + return out.slice(0, MAX_RECENT_KBS); +} + +export function labelForPath(path: string): string { + const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +export function touchRecent( + state: DesktopState, + projectRoot: string, + label?: string, +): DesktopState { + const now = new Date().toISOString(); + const entry: RecentKbEntry = { + path: projectRoot, + label: label ?? labelForPath(projectRoot), + opened_at: now, + }; + const filtered = state.recent_kbs.filter( + (e) => normalizePath(e.path).toLowerCase() !== normalizePath(projectRoot).toLowerCase(), + ); + return { + version: STATE_VERSION, + last_kb: projectRoot, + recent_kbs: dedupeRecent([entry, ...filtered]), + }; +} diff --git a/desktop/app/src/state/StateMigrator.ts b/desktop/app/src/state/StateMigrator.ts new file mode 100644 index 00000000..51164a51 --- /dev/null +++ b/desktop/app/src/state/StateMigrator.ts @@ -0,0 +1,29 @@ +import type { DesktopState } from "../core/types"; +import { STATE_VERSION } from "../core/constants"; +import { emptyState } from "./RecentKbList"; + +export function migrateState(raw: unknown): DesktopState { + if (!raw || typeof raw !== "object") { + return emptyState(); + } + const obj = raw as Record; + const version = typeof obj.version === "number" ? obj.version : 1; + if (version > STATE_VERSION) { + return emptyState(); + } + const recent = Array.isArray(obj.recent_kbs) ? obj.recent_kbs : []; + const parsedRecent = recent + .filter((item): item is Record => !!item && typeof item === "object") + .map((item) => ({ + path: String(item.path ?? ""), + label: String(item.label ?? ""), + opened_at: String(item.opened_at ?? ""), + })) + .filter((item) => item.path.length > 0); + + return { + version: STATE_VERSION, + last_kb: typeof obj.last_kb === "string" ? obj.last_kb : null, + recent_kbs: parsedRecent, + }; +} diff --git a/desktop/app/src/styles/error-panel.css b/desktop/app/src/styles/error-panel.css new file mode 100644 index 00000000..b1143242 --- /dev/null +++ b/desktop/app/src/styles/error-panel.css @@ -0,0 +1,8 @@ +.error-panel h1 { + color: var(--vermillion); +} + +#error-message { + margin-top: 1rem; + line-height: 1.5; +} diff --git a/desktop/app/src/styles/loading.css b/desktop/app/src/styles/loading.css new file mode 100644 index 00000000..e82c596f --- /dev/null +++ b/desktop/app/src/styles/loading.css @@ -0,0 +1,20 @@ +.loading-spinner { + width: 2.5rem; + height: 2.5rem; + margin: 0 auto 1rem; + border: 3px solid var(--rule); + border-top-color: var(--vermillion); + border-radius: 50%; + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +#loading-message { + font-family: var(--sans); + color: var(--muted); +} diff --git a/desktop/app/src/styles/shell.css b/desktop/app/src/styles/shell.css new file mode 100644 index 00000000..a9fcf8bb --- /dev/null +++ b/desktop/app/src/styles/shell.css @@ -0,0 +1,106 @@ +:root { + --paper: #f4eedb; + --ink: #1a1208; + --rule: #1a120833; + --muted: #6b5d4a; + --vermillion: #a82c1c; + --sans: "Inter", system-ui, sans-serif; + --serif: "EB Garamond", "Georgia", serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100vh; + background: var(--paper); + color: var(--ink); + font-family: var(--serif); +} + +.shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 2rem; +} + +.hidden { + display: none !important; +} + +button { + font-family: var(--sans); + font-size: 0.95rem; + padding: 0.65rem 1.1rem; + border: 1px solid var(--ink); + background: var(--ink); + color: var(--paper); + cursor: pointer; +} + +button.secondary { + background: transparent; + color: var(--ink); +} + +button:hover { + filter: brightness(1.05); +} + +.welcome-panel, +.error-panel, +.loading-panel { + max-width: 32rem; + width: 100%; + text-align: center; +} + +.welcome-panel h1, +.error-panel h1 { + font-weight: 400; + font-variant: small-caps; + letter-spacing: 0.08em; +} + +.welcome-actions, +.error-actions { + display: flex; + gap: 0.75rem; + justify-content: center; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.recent-list { + list-style: none; + padding: 0; + margin: 2rem 0 0; + text-align: left; +} + +.recent-item { + width: 100%; + text-align: left; + background: transparent; + color: var(--ink); + border: none; + border-bottom: 1px solid var(--rule); + padding: 0.75rem 0; +} + +.recent-empty { + color: var(--muted); + font-family: var(--sans); + font-size: 0.9rem; +} + +.error-path { + font-family: var(--sans); + font-size: 0.85rem; + color: var(--muted); + word-break: break-all; +} diff --git a/desktop/app/src/ui/ShellController.ts b/desktop/app/src/ui/ShellController.ts new file mode 100644 index 00000000..ed072b11 --- /dev/null +++ b/desktop/app/src/ui/ShellController.ts @@ -0,0 +1,75 @@ +import type { ShellView } from "../core/types"; + +export class ShellController { + private readonly root: HTMLElement; + private readonly loadingMessage: HTMLElement; + private readonly errorMessage: HTMLElement; + private readonly errorPath: HTMLElement; + private readonly recentList: HTMLUListElement; + + constructor() { + const root = document.getElementById("app"); + if (!root) throw new Error("missing #app root"); + this.root = root; + this.loadingMessage = document.getElementById("loading-message")!; + this.errorMessage = document.getElementById("error-message")!; + this.errorPath = document.getElementById("error-path")!; + this.recentList = document.getElementById("recent-list") as HTMLUListElement; + } + + setView(view: ShellView): void { + this.root.dataset.view = view; + for (const section of ["loading", "error", "welcome"] as const) { + const el = document.getElementById(section); + if (!el) continue; + el.classList.toggle("hidden", section !== view); + } + } + + setLoadingMessage(message: string): void { + this.loadingMessage.textContent = message; + } + + showError(selected: string, message: string): void { + this.errorMessage.textContent = message; + this.errorPath.textContent = selected; + this.setView("error"); + } + + renderRecent( + entries: { path: string; label: string }[], + onSelect: (path: string) => void, + ): void { + this.recentList.replaceChildren(); + if (entries.length === 0) { + const li = document.createElement("li"); + li.className = "recent-empty"; + li.textContent = "no recent knowledge bases"; + this.recentList.append(li); + return; + } + for (const entry of entries) { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "recent-item"; + btn.title = entry.path; + btn.textContent = entry.label; + btn.addEventListener("click", () => onSelect(entry.path)); + li.append(btn); + this.recentList.append(li); + } + } + + bindActions(handlers: { + onOpenKb: () => void; + onNewKb: () => void; + onCreateKb: () => void; + onPickFolder: () => void; + }): void { + document.getElementById("btn-open-kb")?.addEventListener("click", handlers.onOpenKb); + document.getElementById("btn-new-kb")?.addEventListener("click", handlers.onNewKb); + document.getElementById("btn-create-kb")?.addEventListener("click", handlers.onCreateKb); + document.getElementById("btn-pick-folder")?.addEventListener("click", handlers.onPickFolder); + } +} diff --git a/desktop/app/tsconfig.json b/desktop/app/tsconfig.json new file mode 100644 index 00000000..482c47ea --- /dev/null +++ b/desktop/app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/desktop/app/vite.config.ts b/desktop/app/vite.config.ts new file mode 100644 index 00000000..4c79292e --- /dev/null +++ b/desktop/app/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + clearScreen: false, + server: { + port: 1420, + strictPort: true, + }, + envPrefix: ["VITE_", "TAURI_"], + build: { + target: "es2021", + minify: !process.env.TAURI_DEBUG ? "esbuild" : false, + sourcemap: !!process.env.TAURI_DEBUG, + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/src/vouch/cli.py b/src/vouch/cli.py index ca465cae..a2953e90 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -145,7 +145,8 @@ def cli() -> None: @cli.command() @click.option("--path", default=".", type=click.Path(file_okay=False), show_default=True) -def init(path: str) -> None: +@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of human text.") +def init(path: str, as_json: bool) -> None: """Initialise a .vouch/ knowledge base at PATH.""" root = Path(path).resolve() root.mkdir(parents=True, exist_ok=True) @@ -153,6 +154,17 @@ def init(path: str) -> None: seed = seed_starter_kb(store, approved_by=_whoami()) health.rebuild_index(store) audit_mod.log_event(store.kb_dir, event="kb.init", actor=_whoami()) + if as_json: + status = health.status(store) + _emit_json({ + "ok": True, + "project_root": str(store.root), + "kb_dir": str(store.kb_dir), + "claim_id": seed.claim_id if seed.created_anything else "", + "starter_present": bool(status.get("claims", 0) >= 1), + "label": store.root.name or str(store.root), + }) + return click.echo(f"Initialised KB at {store.kb_dir}") if seed.created_anything: click.echo(f"Seeded starter claim: {seed.claim_id}") @@ -176,6 +188,80 @@ def discover(path: str) -> None: sys.exit(2) +# --- desktop shell (issue #207) ------------------------------------------ + + +@cli.group() +def desktop() -> None: + """Helpers for the vouch desktop review shell (multi-KB picker).""" + + +@desktop.command("state-show") +@click.option( + "--state-file", + type=click.Path(dir_okay=False), + default=None, + help="Override the default ~/.config/vouch-desktop/state.json path.", +) +def desktop_state_show(state_file: str | None) -> None: + """Emit the persisted desktop state as JSON.""" + from .desktop import load_state + from .desktop.paths import state_file_path + + path = Path(state_file) if state_file else state_file_path() + _emit_json(load_state(path).to_dict()) + + +@desktop.command("state-touch") +@click.argument("project_root", type=click.Path(exists=True, file_okay=False)) +@click.option("--label", default=None, help="Display label for the recent list.") +@click.option( + "--state-file", + type=click.Path(dir_okay=False), + default=None, + help="Override the default state.json path.", +) +def desktop_state_touch( + project_root: str, + label: str | None, + state_file: str | None, +) -> None: + """Record a KB open in the desktop recent list.""" + from .desktop import touch_recent_kb + from .desktop.paths import state_file_path + + path = Path(state_file) if state_file else state_file_path() + state = touch_recent_kb(project_root, label=label, path=path) + _emit_json(state.to_dict()) + + +@desktop.command("kb-check") +@click.argument("selected", type=click.Path(exists=True, file_okay=False)) +def desktop_kb_check(selected: str) -> None: + """Validate that SELECTED contains a .vouch/ directory (JSON).""" + from .desktop.kb import check_kb_folder + + _emit_json(check_kb_folder(selected).to_dict()) + + +@desktop.command("kb-init") +@click.argument("selected", type=click.Path(file_okay=False)) +@click.option("--actor", default="desktop", show_default=True) +def desktop_kb_init(selected: str, actor: str) -> None: + """Initialise a KB at SELECTED (JSON; for the desktop New KB flow).""" + from .desktop.kb import init_kb_at + + _emit_json(init_kb_at(selected, actor=actor)) + + +@desktop.command("config-dir") +def desktop_config_dir() -> None: + """Print the XDG config directory for the desktop shell.""" + from .desktop.paths import config_dir + + click.echo(config_dir()) + + @cli.command() def capabilities() -> None: """Emit the JSON capabilities descriptor (mirrors kb.capabilities).""" diff --git a/src/vouch/desktop/__init__.py b/src/vouch/desktop/__init__.py new file mode 100644 index 00000000..b1ca5634 --- /dev/null +++ b/src/vouch/desktop/__init__.py @@ -0,0 +1,36 @@ +"""Desktop shell helpers for the vouch review console (#207). + +The native Tauri app under ``desktop/app/`` owns menus and window chrome; +this package holds the shared logic the shell can invoke via ``vouch desktop +…`` subprocesses or import in tests: XDG config paths, recent-KB +persistence, folder validation, and review-ui sidecar spawning. +""" + +from .kb import KbCheckResult, check_kb_folder, init_kb_at, kb_label +from .paths import config_dir, state_file_path +from .ports import pick_free_port +from .protocol import kb_check_to_json, kb_init_to_json, state_to_json +from .sidecar import SidecarConfig, SidecarHandle, spawn_review_ui, terminate_sidecar +from .state import DesktopState, RecentKbEntry, load_state, save_state, touch_recent_kb + +__all__ = [ + "DesktopState", + "KbCheckResult", + "RecentKbEntry", + "SidecarConfig", + "SidecarHandle", + "check_kb_folder", + "config_dir", + "init_kb_at", + "kb_check_to_json", + "kb_init_to_json", + "kb_label", + "load_state", + "pick_free_port", + "save_state", + "spawn_review_ui", + "state_file_path", + "state_to_json", + "terminate_sidecar", + "touch_recent_kb", +] diff --git a/src/vouch/desktop/kb.py b/src/vouch/desktop/kb.py new file mode 100644 index 00000000..0b2f28d3 --- /dev/null +++ b/src/vouch/desktop/kb.py @@ -0,0 +1,121 @@ +"""KB folder validation and init helpers for the desktop shell.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .. import audit as audit_mod +from .. import health +from ..onboarding import seed_starter_kb +from ..storage import KB_DIRNAME, KBStore, discover_root + + +@dataclass(frozen=True) +class KbCheckResult: + ok: bool + project_root: str | None + kb_dir: str | None + message: str + + def to_dict(self) -> dict[str, str | bool | None]: + return { + "ok": self.ok, + "project_root": self.project_root, + "kb_dir": self.kb_dir, + "message": self.message, + } + + +def kb_label(project_root: str | Path) -> str: + """Short display label for a project root (basename).""" + root = Path(project_root).resolve() + return root.name or str(root) + + +def _resolve_selection(selected: Path) -> Path: + """Accept either a project root or a ``.vouch`` directory from a picker.""" + resolved = selected.resolve() + if resolved.name == KB_DIRNAME and resolved.is_dir(): + return resolved.parent + return resolved + + +def check_kb_folder(selected: str | Path) -> KbCheckResult: + """Validate that ``selected`` contains (or is) a ``.vouch/`` directory.""" + try: + root = _resolve_selection(Path(selected)) + except OSError as e: + return KbCheckResult( + ok=False, + project_root=None, + kb_dir=None, + message=str(e), + ) + + if not root.is_dir(): + return KbCheckResult( + ok=False, + project_root=str(root), + kb_dir=None, + message=f"{root!s} is not a directory", + ) + + kb_dir = root / KB_DIRNAME + if kb_dir.is_dir(): + return KbCheckResult( + ok=True, + project_root=str(root), + kb_dir=str(kb_dir), + message="ok", + ) + + return KbCheckResult( + ok=False, + project_root=str(root), + kb_dir=str(kb_dir), + message=f"no {KB_DIRNAME}/ directory at {root}", + ) + + +def init_kb_at( + selected: str | Path, + *, + actor: str = "desktop", +) -> dict[str, str | bool]: + """Run ``vouch init`` semantics at ``selected``; return a JSON-friendly dict.""" + root = _resolve_selection(Path(selected)) + root.mkdir(parents=True, exist_ok=True) + store = KBStore.init(root) + seed = seed_starter_kb(store, approved_by=actor) + health.rebuild_index(store) + audit_mod.log_event(store.kb_dir, event="kb.init", actor=actor) + status = health.status(store) + has_starter = bool(status.get("claims", 0) >= 1) + return { + "ok": True, + "project_root": str(store.root), + "kb_dir": str(store.kb_dir), + "claim_id": seed.claim_id if seed.created_anything else "", + "starter_present": has_starter, + "label": kb_label(store.root), + } + + +def discover_from_path(start: str | Path) -> KbCheckResult: + """Walk up from ``start`` like the CLI ``discover`` command.""" + try: + root = discover_root(Path(start)) + except Exception as e: + return KbCheckResult( + ok=False, + project_root=None, + kb_dir=None, + message=str(e), + ) + return KbCheckResult( + ok=True, + project_root=str(root), + kb_dir=str(root / KB_DIRNAME), + message="ok", + ) diff --git a/src/vouch/desktop/paths.py b/src/vouch/desktop/paths.py new file mode 100644 index 00000000..4bb037a1 --- /dev/null +++ b/src/vouch/desktop/paths.py @@ -0,0 +1,43 @@ +"""XDG-compliant config paths for the vouch desktop shell.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_DIRNAME = "vouch-desktop" +STATE_FILENAME = "state.json" + + +def _windows_config_root() -> Path: + appdata = os.environ.get("APPDATA") + if appdata: + return Path(appdata) + return Path.home() / "AppData" / "Roaming" + + +def _xdg_config_home() -> Path: + explicit = os.environ.get("XDG_CONFIG_HOME") + if explicit: + return Path(explicit).expanduser() + if sys.platform == "win32": + return _windows_config_root() + return Path.home() / ".config" + + +def config_dir() -> Path: + """Return ``~/.config/vouch-desktop`` (or platform equivalent).""" + return _xdg_config_home() / APP_DIRNAME + + +def state_file_path() -> Path: + """Absolute path to the persisted desktop state file.""" + return config_dir() / STATE_FILENAME + + +def ensure_config_dir() -> Path: + """Create the config directory if missing; return its path.""" + root = config_dir() + root.mkdir(parents=True, exist_ok=True) + return root diff --git a/src/vouch/desktop/ports.py b/src/vouch/desktop/ports.py new file mode 100644 index 00000000..96c13207 --- /dev/null +++ b/src/vouch/desktop/ports.py @@ -0,0 +1,25 @@ +"""Free localhost port selection for the review-ui sidecar.""" + +from __future__ import annotations + +import socket +from contextlib import closing + + +def pick_free_port(host: str = "127.0.0.1", start: int = 7780, attempts: int = 32) -> int: + """Return the first bindable port in ``[start, start + attempts)``.""" + for port in range(start, start + attempts): + if _port_available(host, port): + return port + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + sock.bind((host, 0)) + return int(sock.getsockname()[1]) + + +def _port_available(host: str, port: int) -> bool: + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: + try: + sock.bind((host, port)) + except OSError: + return False + return True diff --git a/src/vouch/desktop/protocol.py b/src/vouch/desktop/protocol.py new file mode 100644 index 00000000..5ff8fbe5 --- /dev/null +++ b/src/vouch/desktop/protocol.py @@ -0,0 +1,39 @@ +"""JSON shapes exchanged between the desktop shell and ``vouch desktop`` CLI.""" + +from __future__ import annotations + +from typing import Any + +from .kb import KbCheckResult, kb_label +from .state import DesktopState, RecentKbEntry + + +def state_to_json(state: DesktopState) -> dict[str, Any]: + return state.to_dict() + + +def recent_entry_to_json(entry: RecentKbEntry) -> dict[str, str]: + return { + "path": entry.path, + "label": entry.label, + "opened_at": entry.opened_at, + } + + +def kb_check_to_json(result: KbCheckResult) -> dict[str, Any]: + payload = result.to_dict() + if result.ok and result.project_root: + payload["label"] = kb_label(result.project_root) + return payload + + +def kb_init_to_json(result: dict[str, str | bool]) -> dict[str, Any]: + return dict(result) + + +__all__ = [ + "kb_check_to_json", + "kb_init_to_json", + "recent_entry_to_json", + "state_to_json", +] diff --git a/src/vouch/desktop/sidecar.py b/src/vouch/desktop/sidecar.py new file mode 100644 index 00000000..2f87e8ad --- /dev/null +++ b/src/vouch/desktop/sidecar.py @@ -0,0 +1,136 @@ +"""Spawn and terminate the ``vouch review-ui`` sidecar process.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import URLError +from urllib.request import urlopen + +DEFAULT_BIND_HOST = "127.0.0.1" +DEFAULT_BIND_PORT = 7780 +STARTUP_TIMEOUT_S = 30.0 +POLL_INTERVAL_S = 0.15 +TERMINATE_TIMEOUT_S = 5.0 + + +@dataclass +class SidecarConfig: + project_root: str + host: str = DEFAULT_BIND_HOST + port: int = DEFAULT_BIND_PORT + reviewer: str = "desktop-reviewer" + vouch_executable: str | None = None + + +@dataclass +class SidecarHandle: + process: subprocess.Popen[Any] + config: SidecarConfig + base_url: str + + @property + def health_url(self) -> str: + return f"{self.base_url}/healthz" + + +def _vouch_cmd(config: SidecarConfig) -> list[str]: + exe = config.vouch_executable + if exe: + return [exe] + return [sys.executable, "-m", "vouch.cli"] + + +def spawn_review_ui(config: SidecarConfig) -> SidecarHandle: + """Start ``vouch review-ui`` against ``config.project_root``.""" + bind = f"{config.host}:{config.port}" + cmd = [ + *_vouch_cmd(config), + "review-ui", + "--bind", + bind, + "--kb", + config.project_root, + "--no-open-browser", + "--reviewer", + config.reviewer, + ] + env = os.environ.copy() + # Child should not inherit a forced KB path from the parent shell. + env.pop("VOUCH_KB_PATH", None) + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + base_url = f"http://{config.host}:{config.port}" + return SidecarHandle(process=proc, config=config, base_url=base_url) + + +def wait_for_health( + handle: SidecarHandle, + *, + timeout_s: float = STARTUP_TIMEOUT_S, + expected_root: str | None = None, +) -> dict[str, Any]: + """Poll ``/healthz`` until the sidecar is ready or timeout.""" + deadline = time.monotonic() + timeout_s + last_error = "sidecar did not become healthy in time" + while time.monotonic() < deadline: + if handle.process.poll() is not None: + out = "" + if handle.process.stdout is not None: + out = handle.process.stdout.read() or "" + raise RuntimeError( + f"review-ui exited with code {handle.process.returncode}: {out[:500]}" + ) + try: + with urlopen(handle.health_url, timeout=1.0) as resp: + body = resp.read().decode("utf-8") + except (URLError, TimeoutError, OSError) as e: + last_error = str(e) + time.sleep(POLL_INTERVAL_S) + continue + import json + + data = json.loads(body) + if not data.get("ok"): + last_error = "healthz returned ok=false" + time.sleep(POLL_INTERVAL_S) + continue + if expected_root is not None: + kb = str(data.get("kb", "")) + if Path(kb).resolve() != Path(expected_root).resolve(): + last_error = f"healthz kb mismatch: {kb!r} != {expected_root!r}" + time.sleep(POLL_INTERVAL_S) + continue + return data + raise TimeoutError(last_error) + + +def terminate_sidecar( + handle: SidecarHandle, + *, + timeout_s: float = TERMINATE_TIMEOUT_S, +) -> None: + """Gracefully stop a running sidecar.""" + proc = handle.process + if proc.poll() is not None: + return + if sys.platform == "win32": + proc.terminate() + else: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=timeout_s) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=1.0) diff --git a/src/vouch/desktop/state.py b/src/vouch/desktop/state.py new file mode 100644 index 00000000..bc8871c5 --- /dev/null +++ b/src/vouch/desktop/state.py @@ -0,0 +1,112 @@ +"""Recent-KB persistence for the desktop shell (#207).""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .paths import ensure_config_dir, state_file_path + +STATE_VERSION = 1 +MAX_RECENT = 5 + + +@dataclass +class RecentKbEntry: + path: str + label: str + opened_at: str + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> RecentKbEntry: + return cls( + path=str(raw["path"]), + label=str(raw.get("label") or Path(raw["path"]).name), + opened_at=str(raw.get("opened_at") or _now_iso()), + ) + + +@dataclass +class DesktopState: + version: int = STATE_VERSION + last_kb: str | None = None + recent_kbs: list[RecentKbEntry] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "last_kb": self.last_kb, + "recent_kbs": [asdict(e) for e in self.recent_kbs], + } + + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> DesktopState: + version = int(raw.get("version", 1)) + recent_raw = raw.get("recent_kbs") or [] + recent = [RecentKbEntry.from_dict(item) for item in recent_raw if isinstance(item, dict)] + last_kb = raw.get("last_kb") + return cls( + version=version, + last_kb=str(last_kb) if last_kb else None, + recent_kbs=recent, + ) + + +def _now_iso() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat() + + +def _normalize_root(path: str | Path) -> str: + return str(Path(path).resolve()) + + +def load_state(path: Path | None = None) -> DesktopState: + """Load desktop state from disk; return defaults when missing.""" + target = path or state_file_path() + if not target.is_file(): + return DesktopState() + try: + raw = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return DesktopState() + if not isinstance(raw, dict): + return DesktopState() + return DesktopState.from_dict(raw) + + +def save_state(state: DesktopState, path: Path | None = None) -> Path: + """Persist desktop state atomically.""" + target = path or state_file_path() + ensure_config_dir() + payload = json.dumps(state.to_dict(), indent=2, sort_keys=True) + tmp = target.with_suffix(".tmp") + tmp.write_text(payload + "\n", encoding="utf-8") + tmp.replace(target) + return target + + +def touch_recent_kb( + project_root: str | Path, + *, + label: str | None = None, + state: DesktopState | None = None, + path: Path | None = None, +) -> DesktopState: + """Record a KB open: move to front of recents, cap at ``MAX_RECENT``.""" + root = _normalize_root(project_root) + entry_label = label or Path(root).name or root + base = state or load_state(path) + + filtered = [e for e in base.recent_kbs if _normalize_root(e.path) != root] + filtered.insert( + 0, + RecentKbEntry(path=root, label=entry_label, opened_at=_now_iso()), + ) + base.recent_kbs = filtered[:MAX_RECENT] + base.last_kb = root + base.version = STATE_VERSION + save_state(base, path) + return base diff --git a/src/vouch/web/server.py b/src/vouch/web/server.py index 2473fadb..3cafc8a8 100644 --- a/src/vouch/web/server.py +++ b/src/vouch/web/server.py @@ -295,6 +295,7 @@ def build_app( app.state.store = store app.state.hub = hub app.state.auth = auth + kb_display = store.root.name or str(store.root) def reviewer() -> str: """Reviewer identity. With Bearer auth the token's label wins; without @@ -357,6 +358,8 @@ def _tmpl(request: Request, name: str, ctx: dict[str, Any]) -> Any: # browser authenticates via the HttpOnly cookie, not via JS). ctx.setdefault("auth_enabled", auth.enabled) ctx.setdefault("dual_solve_enabled", allow_dual_solve) + ctx.setdefault("kb_label", kb_display) + ctx.setdefault("kb_root", str(store.root)) return templates.TemplateResponse(request, name, ctx) async def _notify(kind: str, **extra: Any) -> None: @@ -369,6 +372,7 @@ def healthz() -> dict[str, Any]: return { "ok": True, "kb": str(store.root), + "kb_label": kb_display, "pending": len(store.list_proposals(ProposalStatus.PENDING)), "auth": auth.enabled, "clients": hub.client_count, diff --git a/src/vouch/web/templates/base.html b/src/vouch/web/templates/base.html index eafa36a1..83ab80a0 100644 --- a/src/vouch/web/templates/base.html +++ b/src/vouch/web/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}vouch review{% endblock %} + {% block title %}{{ kb_label|default('vouch') }} · vouch review{% endblock %} {% block head %}{% endblock %} @@ -12,7 +12,7 @@
- vouch · review console + vouch · {{ kb_label|default('review console') }}