diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..bebf2fd --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,63 @@ +name: Checks + +on: + pull_request: + push: + branches: [main] + workflow_call: + inputs: + ref: + description: Source revision to verify + required: false + type: string + default: '' + +permissions: + contents: read + +jobs: + frontend: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + - uses: actions/setup-node@v7 + with: + node-version: 24.11.1 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run verify + - run: npm audit --audit-level=high + + rust: + name: Rust (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-15, ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: 1.99.0 + components: rustfmt, clippy + - uses: actions/setup-node@v7 + with: + node-version: 24.11.1 + cache: npm + - run: npm ci + - run: npm run build + - run: npx tauri icon src-tauri/icons/icon.svg + - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check + - run: cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets -- -D warnings + - run: cargo test --manifest-path src-tauri/Cargo.toml --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e3b223..bf04f69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,15 @@ permissions: contents: write jobs: + verify: + uses: ./.github/workflows/checks.yml + with: + ref: ${{ github.event.release.tag_name }} + permissions: + contents: read + build: + needs: verify name: Build ${{ matrix.name }} strategy: fail-fast: false @@ -43,12 +51,12 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24.11.1 cache: npm - name: Set up Rust uses: dtolnay/rust-toolchain@v1 with: - toolchain: stable + toolchain: 1.99.0 - name: Install dependencies run: npm ci - name: Generate application icons diff --git a/README.md b/README.md index 2365538..1c3eefd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Pin it above your editor. See what agents are working on, what they've reported - **One board across projects.** Scan agent updates grouped by project and Git branch. - **A window that stays visible.** Enable always-on-top while you work in your editor. - **Updates from your agents.** Connected agents publish and revise short reports through MCP. -- **Less clutter.** Dismiss individual updates or enable age-based cleanup. +- **Less clutter.** Dismiss updates, pin items you want to keep, or enable age/count-based cleanup. Recover automatically removed items from Recently removed. - **Your preferred view.** Adjust theme, color, text size, and project order. Status data stays on your machine. The bundled MCP server receives reports even when the window is closed. @@ -28,6 +28,8 @@ Status data stays on your machine. The bundled MCP server receives reports even 4. Restart your agent client if needed to load its new configuration, then start a fresh conversation. 5. Ask your agent: "Use the sideglance skill to report this task in Sideglance." +Use **Test connection** in Agent integration to check a new MCP connection without publishing a report. Existing sessions still need a restart after updating. + Reporting requires a real session ID from the agent client. Configuration alone does not prove reporting works; confirm that your first update appears in the board. ## Installing on macOS @@ -66,14 +68,17 @@ The first macOS installation still needs the [quarantine command above](#install ## Development -Requires Node 22 or newer, npm, Rust, and the Tauri platform prerequisites. +Requires Node 24.11.1 or newer, npm, Rust, and the Tauri platform prerequisites. CI uses Rust 1.99.0. ```sh -npm install +npm ci +npx playwright install chromium npm run dev -npm test -cargo test --manifest-path src-tauri/Cargo.toml -npm run format:check +# Run verification separately from the development server: +npm run verify +cargo fmt --manifest-path src-tauri/Cargo.toml --check +cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets -- -D warnings +cargo test --manifest-path src-tauri/Cargo.toml --locked ``` Build a desktop bundle with `npm run tauri build`. See the [technical reference](docs/technical-reference.md) for the status schema, MCP contract, source layout, and motion checks. diff --git a/docs/technical-reference.md b/docs/technical-reference.md index 0cbca5e..844a5ba 100644 --- a/docs/technical-reference.md +++ b/docs/technical-reference.md @@ -4,40 +4,50 @@ The MCP manages `~/.local/share/sideglance/status.json`, shared with the desktop ## Status schema and MCP -The schema is defined in [status.schema.json](../status.schema.json). Documents with version `1` remain readable. Successful writes migrate them to version `2`. Each entry stores `folder_name`, nullable `session_id`, `branch`, `points`, and a server-generated RFC 3339 `updated_at` timestamp. Points contain plain `text`, `done`, an optional HTTP or HTTPS `link`, a stable server-assigned `id`, and a `submitted_at` timestamp. An existing point ID keeps its identity through edits, and its submitted time is preserved when the caller omits a replacement timestamp. A caller-supplied valid timestamp is retained. Legacy points receive deterministic IDs on read, so refreshes do not remount them. +The schema is defined in [status.schema.json](../status.schema.json). Documents with version `1` remain readable. Successful writes migrate them to version `2`. Each entry stores `folder_name`, nullable `session_id`, `branch`, `points`, and a server-generated RFC 3339 `updated_at` timestamp. Points contain plain `text`, `done`, an optional HTTP or HTTPS `link`, a stable server-assigned `id`, and a `submitted_at` timestamp. An existing point ID keeps its identity through edits, and its submitted time is preserved when the caller omits a replacement timestamp. Agent payloads contain text, completion state, link and optional ID. Storage timestamps and pin state are server-owned and excluded from the agent protocol. Legacy points receive deterministic IDs on read, so refreshes do not remount them. Both MCP tools require `folder_name` and a non-empty real `session_id`. `read_status` returns only the matching project's session record. `write_status` also accepts `branch` and the complete revised `points` list, replacing that record. Agents read first, then revise text, completion flags, and links. Omitted points are removed; superseded text is not retained as history. Anonymous writes and project-wide MCP reads are not supported. Existing records with a null session ID remain viewable for compatibility. The supplied session ID identifies the record; it is not authentication of the calling harness. Writers hold an OS lock on a stable sidecar file for the entire read/modify/write operation, then atomically replace the JSON. Invalid JSON is reported and preserved. Documents are limited to 1 MiB. -The viewer renders points directly with Svelte and keeps the last valid view if a read fails. It groups by project and branch, with no session labels. Dismissal sends the point ID plus the entry snapshot timestamp; the old point index remains accepted for compatibility. Markdown rendering, source-file selection, and whole-document editing have been removed. +The viewer renders points directly with Svelte and keeps the last valid view if a read fails. It groups by project or repository and branch, and disambiguates concurrent sessions. Health notices identify waiting, stale, and failed reads; text selection pauses layout until cleared or explicitly resumed. Collapsed groups persist locally. Dismissal sends the point ID plus the entry snapshot timestamp; the old point index remains accepted for compatibility. Markdown rendering, source-file selection, and whole-document editing have been removed. External edits to settings are read at startup. Restart the app after changing `settings.json` outside the app. -After upgrading the app, update installed copies of the skill through Settings. Older app and MCP processes cannot read version 2 documents, so upgrade and restart them before the first write. The previous Markdown `status` argument is no longer supported. This project does not migrate a user's status file until a successful write occurs. +After upgrading the app, update installed copies of the skill through Settings and restart agent clients to load the replaced executable. The shared v2 file omits pin fields and pinned points, preserving compatibility with the pre-pin v2 parser. User pins live in `status.private.json` beside `status.json`, with complete point records so an older writer cannot erase them. The new viewer overlays those records. Older viewers cannot display these private pins. The first read of the inline-pin format backs up its original bytes and converts it to the compatible shared format. + +Pin changes use the stable status lock and a recovery journal in the private file. On recovery, a pending write completes only if the raw file still matches its original bytes, or finalizes if the intended bytes already landed. A newer legacy write is preserved, and recovery conservatively retains both previously committed pins and newly pending pins so a crash after the raw commit cannot discard the only copy. Interrupted unpins may remain pinned until retried. Keep both status files together when backing up or recovering; do not delete the private file or copy an old raw backup over active newer data. Lock acquisition fails with an actionable retry error after two seconds. + +Automatic deletion archives each removed point before changing status. Recently removed retains up to 30 days and 5 MiB, evicting oldest records first; it is bounded history, not a permanent backup. Archive failures prevent pruning. Restoring an item pins it so the same deletion rule cannot remove it immediately. Conflicting active points are preserved and reported. Retention previews are estimates because incoming reports can change the affected count. + +Invalid settings are preserved in a unique `settings.invalid-*.backup` file, then validated defaults are loaded with a visible recovery warning. Read/permission errors are reported rather than overwritten. Skill replacements retain hidden `.sideglance.backup-*` directories beside the installation; if replacement is interrupted, quit agents and restore the backup directory to its original name. Config edits preserve permissions and detect changed source bytes immediately before replacement. External editors do not participate in Sideglance locking, so the final check and rename cannot provide an atomic compare-and-swap against arbitrary writers. + +Agent integration distinguishes configured, structurally valid and explicitly tested connections. Test connection performs only MCP initialize and tools/list, with a five-second deadline and a 64 KiB output cap. It creates no status report and terminates its test process. Repair shows the existing target path and replaces Sideglance's registration. A successful fresh connection does not prove an existing agent session has reloaded. The previous Markdown `status` argument is unsupported. ## Updates and releases -Settings checks for updates only when the user selects **Check for updates**. The Tauri updater fetches the public `latest.json` asset from the latest stable GitHub release, verifies the update with the public key embedded in `src-tauri/tauri.conf.json`, then installs it after the user selects **Download and install**. The private counterpart is never committed. It must be stored in GitHub Actions secrets as `TAURI_SIGNING_PRIVATE_KEY`, with `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` when the key has one. +The toolbar starts with a check icon and checks when clicked. Background checks run every three hours with jitter. An available update changes the icon to download; downloading retains the package until the user selects install, which installs and restarts the app. Download and install actions remain visible when the window loses focus. Settings also checks when the user selects **Check for updates**. The Tauri updater fetches the public `latest.json` asset from the latest stable GitHub release, verifies the update with the public key embedded in `src-tauri/tauri.conf.json`, then installs it after the user selects **Download and install**. The private counterpart is never committed. It must be stored in GitHub Actions secrets as `TAURI_SIGNING_PRIVATE_KEY`, with `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` when the key has one. -`.github/workflows/release.yml` starts when a GitHub release is created. It builds signed macOS arm64, Linux x64, and Windows x64 bundles in parallel. A final job uploads one platform manifest after all three builds pass. Update checks may temporarily fail while a new release builds, because `latest.json` is not available until that final job completes. Linux self-update supports AppImage only. Debian and RPM users should update with their package manager. +`.github/workflows/checks.yml` verifies frontend, browser, formatting, and native Rust tests on pull requests and main pushes. The Rust matrix covers macOS, Linux, and Windows. `.github/workflows/release.yml` starts when a GitHub release is created, verifies the tagged source through that same workflow, then builds signed macOS arm64, Linux x64, and Windows x64 bundles in parallel. A final job uploads one platform manifest after all three builds pass. Update checks may temporarily fail while a new release builds, because `latest.json` is not available until that final job completes. Linux self-update supports AppImage only. Debian and RPM users should update with their package manager. -The bundled MCP executable is replaced with the app. A running agent client can keep using the old process until it restarts, so users should restart their client after updating. The updater does not replace copied skills or alter agent configuration. +The bundled MCP executable is replaced with the app. A running agent client can keep using the old process until it restarts, so users should restart their client after updating. The updater does not replace copied skills or alter agent configuration. Available updater resources are closed when settings closes, including checks that resolve after unmount; an in-flight installation retains its resource until completion. ## Development Source is organized by responsibility. The frontend keeps app entry points at `src/`, with viewer code in `src/viewer/`, settings in `src/settings/`, integrations in `src/integrations/`, and shared types and Tauri helpers in `src/shared/`. Rust puts status storage and refresh code in `src-tauri/src/status/`, preferences in `preferences/`, desktop commands and windows in `desktop/`, and integration adapters in `integrations/`. ```sh -npm install +npm ci +npx playwright install chromium npm run dev -npm run build -cargo test --manifest-path src-tauri/Cargo.toml -npm test -npm run format:check +# Run verification separately from the development server: +npm run verify +cargo fmt --manifest-path src-tauri/Cargo.toml --check +cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets -- -D warnings +cargo test --manifest-path src-tauri/Cargo.toml --locked ``` -The local checks need Node 22 or newer for `--experimental-strip-types`, npm, Rust, and the Tauri platform prerequisites. Install the Playwright browser once with `npx playwright install chromium` before running `npm run test:motion`. The motion test uses the fixture under `tests/` and does not access real app data. +The local checks need Node 24.11.1 or newer, npm, Rust, and the Tauri platform prerequisites. CI uses Rust 1.99.0. Install the Playwright browser once with `npx playwright install chromium` before running `npm run test:motion`. The motion test uses the fixture under `tests/` and does not access real app data. To build a desktop bundle, install Rust and the Tauri prerequisites, then run `npm run tauri build`. @@ -45,3 +55,11 @@ For frame-by-frame viewer motion regression checks, run `npm run dev` and open `http://localhost:1420/tests/viewer-motion.html`. The tab title reports pass/fail; `await window.motionResults` in the browser console returns each result. The fixture mocks Tauri IPC and does not change your saved statuses. + +## Resource usage and validation limits + +The headless MCP uses a current-thread runtime and blocking workers for storage. A three-client macOS Apple M4 sample reduced thread counts from 13 per process to 3–4 while retaining similar write latency. Frontend settings and Markdown rendering load as a separate route chunk. Decorative SVG playback pauses on blur, document hiding and reduced-motion preference; merely hiding the animation element with CSS is insufficient. Background throttling remains disabled until native event-delivery checks justify changing it. + +Release builds strip symbols. A local macOS executable experiment reduced 18.85 MB to 13.43 MB and passed the temporary-home MCP workload. These are executable sizes, not installer or whole-app measurements. Local target caches and tracked generated schemas are separate from distributed bundle size. LTO and codegen-unit changes have not been adopted without further measurements. + +PR checks cover frontend unit/browser/type/format checks, a high-severity npm audit gate, and Rust format/Clippy/tests across macOS, Linux, and Windows. The current npm audit retains two low findings through the latest Svelte build plugin's deepmerge dependency, which handles trusted local compiler configuration. There is no upstream fix reported by npm; recheck the exception on updates. RustSec tooling was unavailable during local remediation. Platform CI and native visibility/minimize/event-delivery behavior require their own verification; Chromium fixtures establish frontend behavior, not native WebKit behavior. diff --git a/package-lock.json b/package-lock.json index 280fb76..dd51d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "svelte-spa-router": "^5.1.1" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tauri-apps/cli": "^2.8.0", "@types/node": "^22.10.0", "playwright": "^1.64.1", @@ -25,13 +25,27 @@ "prettier-plugin-svelte": "^4.1.1", "svelte-check": "^4.7.7", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^8.3.8" + }, + "engines": { + "node": ">=24.11.1" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.2.0.tgz", + "integrity": "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -46,9 +60,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -63,9 +77,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -80,9 +94,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -97,9 +111,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -114,9 +128,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -131,9 +145,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -148,9 +162,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -165,9 +179,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -182,9 +196,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -199,9 +213,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -216,9 +230,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -233,9 +247,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -250,9 +264,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -267,9 +281,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -284,9 +298,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -301,9 +315,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -318,9 +332,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -335,9 +349,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -352,9 +366,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -369,9 +383,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -386,9 +400,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -403,9 +417,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -420,9 +434,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -437,9 +451,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -454,9 +468,9 @@ } }, "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==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -515,27 +529,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.0.tgz", - "integrity": "sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==", + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.19.tgz", + "integrity": "sha512-e+Ebe0WT+9tbXmNKPIiC8YrWAHYwug3m6i4p3rZGGulfZri1jtI4HslT+OZnFf/OIHowWF71DTlHwXBP2SiA7A==", "cpu": [ "arm" ], @@ -544,12 +573,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.0.tgz", - "integrity": "sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.19.tgz", + "integrity": "sha512-hOombrTJ+szt2ciEzS7oCA78lMPBgF5UMQWCJIyNfnrI7GLl8HYoFqCnpL2gcPOaXMqryGCU5ExdQKyDDcC+yA==", "cpu": [ "arm64" ], @@ -558,12 +590,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.0.tgz", - "integrity": "sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.19.tgz", + "integrity": "sha512-XoMA2pwCKGTQzUjPgtRwkedH4WmXhdIPKT6l6oOxd1PUbNodGI6DcBekgN7EC//+x3pfBYX/qL1gNmZAqivw5g==", "cpu": [ "arm64" ], @@ -572,12 +607,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.0.tgz", - "integrity": "sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.19.tgz", + "integrity": "sha512-zo8EsSSwMZCY/e+fRnGaX02YGgvJD0yvhNi/9hpGPA2MJ9ZF8dPCw75UGfpI6OpQ/myR/dIYmogRvqiZD0TrXg==", "cpu": [ "x64" ], @@ -586,26 +624,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.0.tgz", - "integrity": "sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.0.tgz", - "integrity": "sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.19.tgz", + "integrity": "sha512-O8ISAOfI1nj7AuBPd30FSF47z7f7lzlk5poQVGgiikvQuGEBFYvW8ki+UEnT+YJPnNGjQzn304klt4O9FUE7mA==", "cpu": [ "x64" ], @@ -614,26 +641,15 @@ "optional": true, "os": [ "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.0.tgz", - "integrity": "sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.0.tgz", - "integrity": "sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.19.tgz", + "integrity": "sha512-/lYCba5NO0yq4S0jGMH8QTBtkvQxoXjoQWzF5BcWp9MjB9jxVRD1GQDNofkVjcOj30DWyhLIN/4Oe60JKzaONw==", "cpu": [ "arm" ], @@ -642,26 +658,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.0.tgz", - "integrity": "sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.0.tgz", - "integrity": "sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.19.tgz", + "integrity": "sha512-pm/apFMBnSbFb3O6nG5pHO1iqHhZrjGDFEjSm+ot9J17XXPvH9L90y5h+jgfHCE+Qdlsvy8qKEcU4VKxgjD+vA==", "cpu": [ "arm64" ], @@ -670,54 +675,32 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.0.tgz", - "integrity": "sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.0.tgz", - "integrity": "sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.19.tgz", + "integrity": "sha512-K1SmQp3FFichn8oqrNIwvFoZ5wcvjC7goBzpaMNvLf9FZc+QB/QvLmnpJZgAAGHiD980tP8FZZnXk2K94rvlxg==", "cpu": [ - "loong64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.0.tgz", - "integrity": "sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.0.tgz", - "integrity": "sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.19.tgz", + "integrity": "sha512-I0xwD57jP/3A7+5UPZin2w8Y7XHEmEM5zqpYZOcQ45MlNW6tUJUziymV3yRr7rL4vwyfEcRsRksC4a7Kc08p5Q==", "cpu": [ "ppc64" ], @@ -726,40 +709,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.0.tgz", - "integrity": "sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==", - "cpu": [ - "riscv64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.0.tgz", - "integrity": "sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.0.tgz", - "integrity": "sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.19.tgz", + "integrity": "sha512-BrtEHT24SNXj9HiXqy+s/kXiSiQGt6EKCPUlRirAoIioVxKGnbXBUEkbZASAei7iZJB0/Ynm+0JP00Ipx7AnSw==", "cpu": [ "s390x" ], @@ -768,12 +726,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.0.tgz", - "integrity": "sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.19.tgz", + "integrity": "sha512-rm75/9DkHKeU2WnA8w4X94WGYgLlDCFCfkQC+4bguVJmC/jvDRv2B+2wLOYhyE0fVuv7//Ckk39OoOufyCu76Q==", "cpu": [ "x64" ], @@ -782,12 +743,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.0.tgz", - "integrity": "sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.19.tgz", + "integrity": "sha512-irh/4+iYFM4GAzpluDErgjz89bj6NDtqqMAG/6O1LG38Ed3NpyebzCpcVNkFW8uhy+/3/BocUvnXDR33CXWe9A==", "cpu": [ "x64" ], @@ -796,26 +760,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.0.tgz", - "integrity": "sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.0.tgz", - "integrity": "sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.19.tgz", + "integrity": "sha512-+KWTWNoPt+CfSq94CiceWu3yPE+QzkaG+phPyz9G60tmZLwfQkcA4AeksafzvJAw4pvg1mcd5a1TkUqVv0s4JA==", "cpu": [ "arm64" ], @@ -824,40 +777,51 @@ "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.0.tgz", - "integrity": "sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.19.tgz", + "integrity": "sha512-7EH+skOsl5qpWnK0vzamgJFhOv1cwxQpsxm8tI883K9fur5c27p8q87fzEO6BbBPabA2/+s/zR63/pLc137iOg==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@emnapi/core": "2.0.0-beta.2", + "@emnapi/runtime": "2.0.0-beta.2", + "@napi-rs/wasm-runtime": "1.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.0.tgz", - "integrity": "sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.19.tgz", + "integrity": "sha512-3Q1Yg1Q7tEvBWoZf5rFvG/GKZQPgwfru+p4wBajDfJFBs0SHCpL++dfTjW9gyAKd1T7ZpIYvdaUax7Pa/IhFVQ==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.0.tgz", - "integrity": "sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.19.tgz", + "integrity": "sha512-y/sqRRc6d7hECzOFc5n3LSeqctmIp9mMjY1Kq4s14uLPd/LZ4z7i1zrW6fMtp2mPeuQIC4e6ULMcwQzMomqQag==", "cpu": [ "x64" ], @@ -866,21 +830,17 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.0.tgz", - "integrity": "sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz", + "integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.13", @@ -902,44 +862,33 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@tauri-apps/api": { @@ -1178,6 +1127,17 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1258,24 +1218,6 @@ "node": ">=6" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1286,6 +1228,16 @@ "node": ">=0.10.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { "version": "5.9.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", @@ -1301,48 +1253,6 @@ "@types/trusted-types": "^2.0.7" } }, - "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/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1408,14 +1318,265 @@ "@types/estree": "^1.0.6" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-character": { @@ -1455,17 +1616,10 @@ "node": ">=4" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -1481,6 +1635,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1532,9 +1700,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -1552,9 +1720,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1614,50 +1782,39 @@ "node": ">=8" } }, - "node_modules/rollup": { - "version": "4.64.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.0.tgz", - "integrity": "sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==", + "node_modules/rolldown": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.19.tgz", + "integrity": "sha512-QIiV1J9d0pzN4jeeTH4sjPy0AZ6cAy2cG0ExeoE1+U8Rvj+hR3gYkokx0YbeUNGlZqiQJJ2utcETe0oKXwzsuA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.64.0", - "@rollup/rollup-android-arm64": "4.64.0", - "@rollup/rollup-darwin-arm64": "4.64.0", - "@rollup/rollup-darwin-x64": "4.64.0", - "@rollup/rollup-freebsd-arm64": "4.64.0", - "@rollup/rollup-freebsd-x64": "4.64.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.64.0", - "@rollup/rollup-linux-arm-musleabihf": "4.64.0", - "@rollup/rollup-linux-arm64-gnu": "4.64.0", - "@rollup/rollup-linux-arm64-musl": "4.64.0", - "@rollup/rollup-linux-loong64-gnu": "4.64.0", - "@rollup/rollup-linux-loong64-musl": "4.64.0", - "@rollup/rollup-linux-ppc64-gnu": "4.64.0", - "@rollup/rollup-linux-ppc64-musl": "4.64.0", - "@rollup/rollup-linux-riscv64-gnu": "4.64.0", - "@rollup/rollup-linux-riscv64-musl": "4.64.0", - "@rollup/rollup-linux-s390x-gnu": "4.64.0", - "@rollup/rollup-linux-x64-gnu": "4.64.0", - "@rollup/rollup-linux-x64-musl": "4.64.0", - "@rollup/rollup-openbsd-x64": "4.64.0", - "@rollup/rollup-openharmony-arm64": "4.64.0", - "@rollup/rollup-win32-arm64-msvc": "4.64.0", - "@rollup/rollup-win32-ia32-msvc": "4.64.0", - "@rollup/rollup-win32-x64-gnu": "4.64.0", - "@rollup/rollup-win32-x64-msvc": "4.64.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm-eabi": "1.2.19", + "@rolldown/binding-android-arm64": "1.2.19", + "@rolldown/binding-darwin-arm64": "1.2.19", + "@rolldown/binding-darwin-x64": "1.2.19", + "@rolldown/binding-freebsd-x64": "1.2.19", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.19", + "@rolldown/binding-linux-arm64-gnu": "1.2.19", + "@rolldown/binding-linux-arm64-musl": "1.2.19", + "@rolldown/binding-linux-ppc64-gnu": "1.2.19", + "@rolldown/binding-linux-s390x-gnu": "1.2.19", + "@rolldown/binding-linux-x64-gnu": "1.2.19", + "@rolldown/binding-linux-x64-musl": "1.2.19", + "@rolldown/binding-openharmony-arm64": "1.2.19", + "@rolldown/binding-wasm32-wasi": "1.2.19", + "@rolldown/binding-win32-arm64-msvc": "1.2.19", + "@rolldown/binding-win32-x64-msvc": "1.2.19" } }, "node_modules/sade": { @@ -1767,6 +1924,14 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1790,25 +1955,24 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "8.3.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.8.tgz", + "integrity": "sha512-LzOn3h9rG/0vhLdXrV4yJ4QFA9eEoqCl+Yx+kGJd7aB9w6nfNgVHKSYwLPZUHrz4ZGa+t+kXjV9itvcHDLQouA==", "dev": true, "license": "MIT", "peer": true, "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" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.32", + "rolldown": "~1.2.18", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -1817,14 +1981,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -1833,13 +1998,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { diff --git a/package.json b/package.json index a618bfd..91973be 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "test:lifecycle": "node --experimental-strip-types --test tests/settings-lifecycle.test.ts tests/update-schedule.test.ts", "test:retention": "node --experimental-strip-types --test tests/settings-retention.test.ts", "test:motion": "playwright test tests/viewer-motion.spec.ts", - "test": "npm run test:rows && npm run test:controller && npm run test:lifecycle && npm run test:retention && npm run check && npm run typecheck && npm run build" + "test": "npm run test:rows && npm run test:controller && npm run test:lifecycle && npm run test:retention && npm run check && npm run typecheck && npm run build", + "test:browser": "playwright test", + "verify": "npm test && npm run test:browser && npm run format:check" }, "dependencies": { "@tauri-apps/api": "^2.8.0", @@ -30,7 +32,7 @@ "svelte-spa-router": "^5.1.1" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tauri-apps/cli": "^2.8.0", "@types/node": "^22.10.0", "playwright": "^1.64.1", @@ -38,6 +40,9 @@ "prettier-plugin-svelte": "^4.1.1", "svelte-check": "^4.7.7", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^8.3.8" + }, + "engines": { + "node": ">=24.11.1" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4bae38b..5d1a741 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3277,6 +3277,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3963,6 +3973,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ceeb0c0..6f68aab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ tauri = { version = "2.12", features = [] } notify = "8" chrono = { version = "0.4", features = ["clock"] } rmcp = { version = "3.5.1", features = ["transport-io"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std"] } +tokio = { version = "1", features = ["rt", "io-std", "process", "time", "io-util"] } tempfile = "3" schemars = "1" uuid = { version = "1", features = ["v4", "v5"] } @@ -42,3 +42,6 @@ default = ["custom-protocol"] [profile.dev] debug = 0 incremental = false + +[profile.release] +strip = "symbols" diff --git a/src-tauri/src/desktop/commands.rs b/src-tauri/src/desktop/commands.rs index 4e08dd2..e5c3c8b 100644 --- a/src-tauri/src/desktop/commands.rs +++ b/src-tauri/src/desktop/commands.rs @@ -16,13 +16,24 @@ use crate::{ status::snapshot::{update, Snapshot, ViewerState}, }; +// Filesystem operations, process waits and status locks never occupy the UI thread. +async fn blocking( + work: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(work) + .await + .map_err(|error| format!("Background operation failed: {error}"))? +} + #[tauri::command] pub(crate) fn get_snapshot(state: State<'_, Arc>) -> Snapshot { state.snapshot.lock().unwrap().clone() } +// Keep the existing flat IPC arguments, including injected Tauri state. +#[allow(clippy::too_many_arguments)] #[tauri::command] -pub(crate) fn set_point_pinned( +pub(crate) async fn set_point_pinned( folder_name: String, session_id: Option, branch: String, @@ -32,23 +43,29 @@ pub(crate) fn set_point_pinned( app: AppHandle, state: State<'_, Arc>, ) -> Result<(), String> { - state - .store - .set_point_pinned( - &folder_name, - session_id.as_deref(), - &branch, - &updated_at, - &point_id, - pinned, - ) - .map_err(|error| error.to_string())?; - update(&app, &state); - Ok(()) + let state = state.inner().clone(); + blocking(move || { + state + .store + .set_point_pinned( + &folder_name, + session_id.as_deref(), + &branch, + &updated_at, + &point_id, + pinned, + ) + .map_err(|error| error.to_string())?; + update(&app, &state); + Ok(()) + }) + .await } +// Keep the existing flat IPC arguments, including injected Tauri state. +#[allow(clippy::too_many_arguments)] #[tauri::command] -pub(crate) fn dismiss_point( +pub(crate) async fn dismiss_point( folder_name: String, session_id: Option, branch: String, @@ -58,159 +75,209 @@ pub(crate) fn dismiss_point( app: AppHandle, state: State<'_, Arc>, ) -> Result<(), String> { - if !state - .store - .dismiss( - &folder_name, - session_id.as_deref(), - &branch, - &updated_at, - point_id.as_deref().unwrap_or(""), - point_index, - ) - .map_err(|error| error.to_string())? - { - return Err("This point changed before it could be dismissed. Please try again.".into()); - } - update(&app, &state); - Ok(()) + let state = state.inner().clone(); + blocking(move || { + if !state + .store + .dismiss( + &folder_name, + session_id.as_deref(), + &branch, + &updated_at, + point_id.as_deref().unwrap_or(""), + point_index, + ) + .map_err(|error| error.to_string())? + { + return Err( + "This point changed before it could be dismissed. Please try again.".into(), + ); + } + update(&app, &state); + Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn restore_point( +pub(crate) async fn restore_point( entry: crate::status::Entry, point_index: usize, app: AppHandle, state: State<'_, Arc>, ) -> Result<(), String> { - state - .store - .restore(entry, point_index) - .map_err(|error| error.to_string())?; - update(&app, &state); - Ok(()) + let state = state.inner().clone(); + blocking(move || { + state + .store + .restore(entry, point_index) + .map_err(|error| error.to_string())?; + update(&app, &state); + Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_undo_limit( +pub(crate) async fn set_undo_limit( undo_limit: usize, owner: State<'_, Arc>, ) -> Result<(), String> { - owner.update(|settings| { - settings.undo_limit = undo_limit; + let owner = owner.inner().clone(); + blocking(move || { + owner.update(|settings| { + settings.undo_limit = undo_limit; + Ok(()) + })?; Ok(()) - })?; - Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn get_settings(owner: State<'_, Arc>) -> Result { - Ok(owner.snapshot()) +pub(crate) async fn get_settings(owner: State<'_, Arc>) -> Result { + let owner = owner.inner().clone(); + blocking(move || Ok(owner.snapshot())).await } #[tauri::command] -pub(crate) fn reset_settings( +pub(crate) async fn reset_settings( app: AppHandle, owner: State<'_, Arc>, state: State<'_, Arc>, ) -> Result { - let settings = owner.reset()?; - app.get_webview_window("main") - .ok_or_else(|| "Main window unavailable".to_string())? - .set_always_on_top(settings.pinned) - .map_err(|error| error.to_string())?; - update(&app, &state); - Ok(settings) + let owner = owner.inner().clone(); + let state = state.inner().clone(); + blocking(move || { + let settings = owner.reset()?; + app.get_webview_window("main") + .ok_or_else(|| "Main window unavailable".to_string())? + .set_always_on_top(settings.pinned) + .map_err(|error| error.to_string())?; + update(&app, &state); + Ok(settings) + }) + .await } #[tauri::command] -pub(crate) fn open_settings(app: AppHandle) { - show_settings_window(&app); +pub(crate) fn open_settings(app: AppHandle) -> Result<(), String> { + show_settings_window(&app).map_err(|error| error.to_string()) } #[tauri::command] -pub(crate) fn set_appearance_settings( +pub(crate) async fn set_appearance_settings( theme: String, primary_color: String, text_scale: f64, owner: State<'_, Arc>, ) -> Result<(), String> { - owner.set_appearance(&theme, primary_color, text_scale)?; - Ok(()) + let owner = owner.inner().clone(); + blocking(move || { + owner.set_appearance(&theme, primary_color, text_scale)?; + Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_status_grouping( +pub(crate) async fn set_status_grouping( status_grouping: crate::preferences::StatusGrouping, owner: State<'_, Arc>, ) -> Result<(), String> { - owner.update(|settings| { - settings.status_grouping = status_grouping; + let owner = owner.inner().clone(); + blocking(move || { + owner.update(|settings| { + settings.status_grouping = status_grouping; + Ok(()) + })?; Ok(()) - })?; - Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_project_sort_order( +pub(crate) async fn set_project_sort_order( project_sort_order: String, owner: State<'_, Arc>, ) -> Result<(), String> { - owner.set_sort_order(&project_sort_order)?; - Ok(()) + let owner = owner.inner().clone(); + blocking(move || { + owner.set_sort_order(&project_sort_order)?; + Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_status_sort_order( +pub(crate) async fn set_status_sort_order( status_sort_order: String, owner: State<'_, Arc>, ) -> Result<(), String> { - owner.set_status_sort_order(&status_sort_order)?; - Ok(()) + let owner = owner.inner().clone(); + blocking(move || { + owner.set_status_sort_order(&status_sort_order)?; + Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_auto_clear_after_minutes( +pub(crate) async fn set_auto_clear_after_minutes( auto_clear_after_minutes: Option, app: AppHandle, owner: State<'_, Arc>, state: State<'_, Arc>, ) -> Result<(), String> { - owner.set_retention(auto_clear_after_minutes)?; - crate::status::retention::clear_expired_points(&state, &owner) - .map_err(|error| format!("Auto-clear setting saved, but pruning failed: {error}"))?; - update(&app, &state); - Ok(()) + let owner = owner.inner().clone(); + let state = state.inner().clone(); + blocking(move || { + owner.set_retention(auto_clear_after_minutes)?; + crate::status::retention::clear_expired_points(&state, &owner) + .map_err(|error| format!("Auto-clear setting saved, but pruning failed: {error}"))?; + update(&app, &state); + Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_max_status_items_per_project( +pub(crate) async fn set_max_status_items_per_project( max_status_items_per_project: Option, app: AppHandle, owner: State<'_, Arc>, state: State<'_, Arc>, ) -> Result<(), String> { - owner.update(|settings| { - settings.max_status_items_per_project = max_status_items_per_project; + let owner = owner.inner().clone(); + let state = state.inner().clone(); + blocking(move || { + owner.update(|settings| { + settings.max_status_items_per_project = max_status_items_per_project; + Ok(()) + })?; + crate::status::retention::clear_expired_points(&state, &owner) + .map_err(|error| format!("Count setting saved, but pruning failed: {error}"))?; + update(&app, &state); Ok(()) - })?; - crate::status::retention::clear_expired_points(&state, &owner) - .map_err(|error| format!("Count setting saved, but pruning failed: {error}"))?; - update(&app, &state); - Ok(()) + }) + .await } #[tauri::command] -pub(crate) fn set_pinned( +pub(crate) async fn set_pinned( pinned: bool, app: AppHandle, owner: State<'_, Arc>, ) -> Result<(), String> { - owner.set_pinned(pinned)?; - app.get_webview_window("main") - .ok_or_else(|| "Main window unavailable".to_string())? - .set_always_on_top(pinned) - .map_err(|error| error.to_string()) + let owner = owner.inner().clone(); + blocking(move || { + owner.set_pinned(pinned)?; + app.get_webview_window("main") + .ok_or_else(|| "Main window unavailable".to_string())? + .set_always_on_top(pinned) + .map_err(|error| error.to_string()) + }) + .await } #[tauri::command] @@ -219,106 +286,219 @@ pub(crate) fn restart_app(app: AppHandle) { } #[tauri::command] -pub(crate) fn get_skill_installation(app: AppHandle) -> Result { - integration_status(&app) +pub(crate) async fn get_skill_installation(app: AppHandle) -> Result { + blocking(move || integration_status(&app)).await } #[tauri::command] -pub(crate) fn install_skill(app: AppHandle) -> Result { - let source = skills::bundled_source(&app)?; - for (_, _, destination) in skills::targets(&app)? { - skills::install_at(&source, &destination)?; - } - integration_status(&app) +pub(crate) async fn install_skill(app: AppHandle) -> Result { + blocking(move || { + let source = skills::bundled_source(&app)?; + for (_, _, destination) in skills::targets(&app)? { + skills::install_at(&source, &destination)?; + } + integration_status(&app) + }) + .await } #[tauri::command] -pub(crate) fn install_skill_for_target( +pub(crate) async fn install_skill_for_target( target: String, app: AppHandle, ) -> Result { - let source = skills::bundled_source(&app)?; - let destination = skills::target(&app, &target)?; - skills::install_at(&source, &destination)?; - integration_status(&app) + blocking(move || { + let source = skills::bundled_source(&app)?; + let destination = skills::target(&app, &target)?; + skills::install_at(&source, &destination)?; + integration_status(&app) + }) + .await } #[tauri::command] -pub(crate) fn install_mcp_for_harness( +pub(crate) async fn install_mcp_for_harness( harness: String, app: AppHandle, ) -> Result { - let executable = std::env::current_exe() - .map_err(|error| format!("Unable to locate the Sideglance executable: {error}"))?; - let home = paths::home_dir(&app)?; - if let Some(directory) = harness.strip_prefix("codex:") { - let directory = PathBuf::from(directory); - if additional_codex_directory(&home, &harness).is_some() { - register_codex_mcp_at(&directory.join("config.toml"), &executable)?; - return integration_status(&app); - } - } - if !harness_available(&harness, &home) { - return Err(format!("{harness} was not found on this computer")); - } - let agent = descriptor(&harness).ok_or_else(|| format!("Unsupported harness: {harness}"))?; - if !agent.supports_mcp { - return Err(format!("{harness} does not support MCP installation")); - } - match agent.adapter { - ConfigAdapter::CodexToml => register_codex_mcp(&config_path(&home, agent), &executable)?, - ConfigAdapter::ClaudeCli => { - if !install_claude_mcp(&executable, &config_path(&home, agent))? { - return Err( - "Claude Code is not installed; its MCP tool was not registered.".into(), - ); + blocking(move || { + let executable = std::env::current_exe() + .map_err(|error| format!("Unable to locate the Sideglance executable: {error}"))?; + let home = paths::home_dir(&app)?; + if let Some(directory) = harness.strip_prefix("codex:") { + let directory = PathBuf::from(directory); + if additional_codex_directory(&home, &harness).is_some() { + register_codex_mcp_at(&directory.join("config.toml"), &executable)?; + return integration_status(&app); } } - ConfigAdapter::StandardJson => { - register_standard_json_mcp(&config_path(&home, agent), &executable)? + if !harness_available(&harness, &home) { + return Err(format!("{harness} was not found on this computer")); } - ConfigAdapter::OpenCodeJson => { - register_opencode_mcp(&config_path(&home, agent), &executable)? + let agent = + descriptor(&harness).ok_or_else(|| format!("Unsupported harness: {harness}"))?; + if !agent.supports_mcp { + return Err(format!("{harness} does not support MCP installation")); } - } - integration_status(&app) + match agent.adapter { + ConfigAdapter::CodexToml => { + register_codex_mcp(&config_path(&home, agent), &executable)? + } + ConfigAdapter::ClaudeCli => { + if !install_claude_mcp(&executable, &config_path(&home, agent))? { + return Err( + "Claude Code is not installed; its MCP tool was not registered.".into(), + ); + } + } + ConfigAdapter::StandardJson => { + register_standard_json_mcp(&config_path(&home, agent), &executable)? + } + ConfigAdapter::OpenCodeJson => { + register_opencode_mcp(&config_path(&home, agent), &executable)? + } + } + integration_status(&app) + }) + .await } #[tauri::command] -pub(crate) fn uninstall_mcp_for_harness( +pub(crate) async fn uninstall_mcp_for_harness( harness: String, app: AppHandle, ) -> Result { - let home = paths::home_dir(&app)?; - if let Some(directory) = harness.strip_prefix("codex:") { - let directory = PathBuf::from(directory); - if additional_codex_directory(&home, &harness).is_some() { - unregister_codex_mcp_at(&directory.join("config.toml"))?; - return integration_status(&app); + blocking(move || { + let home = paths::home_dir(&app)?; + if let Some(directory) = harness.strip_prefix("codex:") { + let directory = PathBuf::from(directory); + if additional_codex_directory(&home, &harness).is_some() { + unregister_codex_mcp_at(&directory.join("config.toml"))?; + return integration_status(&app); + } } - } - if !harness_available(&harness, &home) { - return Err(format!("{harness} was not found on this computer")); - } - let agent = descriptor(&harness).ok_or_else(|| format!("Unsupported harness: {harness}"))?; - if !agent.supports_mcp { - return Err(format!("{harness} does not support MCP removal")); - } - match agent.adapter { - ConfigAdapter::CodexToml => { - crate::integrations::config::unregister_codex_mcp_at(&config_path(&home, agent))? + if !harness_available(&harness, &home) { + return Err(format!("{harness} was not found on this computer")); } - ConfigAdapter::ClaudeCli => { - if !uninstall_claude_mcp()? { - return Err("Claude Code is not installed; its MCP tool was not removed.".into()); + let agent = + descriptor(&harness).ok_or_else(|| format!("Unsupported harness: {harness}"))?; + if !agent.supports_mcp { + return Err(format!("{harness} does not support MCP removal")); + } + match agent.adapter { + ConfigAdapter::CodexToml => { + crate::integrations::config::unregister_codex_mcp_at(&config_path(&home, agent))? + } + ConfigAdapter::ClaudeCli => { + if !uninstall_claude_mcp()? { + return Err( + "Claude Code is not installed; its MCP tool was not removed.".into(), + ); + } + } + ConfigAdapter::StandardJson => { + unregister_json_mcp(&config_path(&home, agent), &["mcpServers"])? + } + ConfigAdapter::OpenCodeJson => { + unregister_json_mcp(&config_path(&home, agent), &["mcp", "servers"])? } } - ConfigAdapter::StandardJson => { - unregister_json_mcp(&config_path(&home, agent), &["mcpServers"])? + integration_status(&app) + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blocking_operations_run_off_the_calling_thread_and_propagate_errors() { + let caller = std::thread::current().id(); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + runtime.block_on(async { + let worker = blocking(move || Ok(std::thread::current().id())) + .await + .unwrap(); + assert_ne!(caller, worker); + let failure: Result<(), String> = blocking(|| Err("lock timeout".into())).await; + assert_eq!(failure.unwrap_err(), "lock timeout"); + }); + } +} + +#[tauri::command] +pub(crate) async fn get_recently_removed( + state: State<'_, Arc>, +) -> Result, String> { + let state = state.inner().clone(); + blocking(move || { + state + .store + .list_removed() + .map_err(|error| error.to_string()) + }) + .await +} + +#[tauri::command] +pub(crate) async fn restore_removed( + id: String, + app: AppHandle, + state: State<'_, Arc>, +) -> Result<(), String> { + let state = state.inner().clone(); + blocking(move || { + state + .store + .restore_removed(&id) + .map_err(|error| error.to_string())?; + update(&app, &state); + Ok(()) + }) + .await +} + +#[tauri::command] +pub(crate) async fn preview_retention( + auto_clear_after_minutes: Option, + max_status_items_per_project: Option, + state: State<'_, Arc>, +) -> Result { + if auto_clear_after_minutes.is_some_and(|minutes| !(1..=43_200).contains(&minutes)) + || max_status_items_per_project == Some(0) + { + return Err("Choose a valid age or item count before reviewing removal".into()); + } + let state = state.inner().clone(); + blocking(move || { + let mut rules = Vec::new(); + if let Some(minutes) = auto_clear_after_minutes { + rules.push(crate::status::domain::RetentionRule::Age( + std::time::Duration::from_secs(minutes * 60), + )); } - ConfigAdapter::OpenCodeJson => { - unregister_json_mcp(&config_path(&home, agent), &["mcp", "servers"])? + if let Some(count) = max_status_items_per_project { + rules.push(crate::status::domain::RetentionRule::CountPerProject(count)); } - } - integration_status(&app) + state + .store + .preview_retention(&rules) + .map_err(|error| error.to_string()) + }) + .await +} + +#[tauri::command] +pub(crate) async fn test_mcp_connection( + harness: String, + app: AppHandle, +) -> Result { + blocking(move || { + let home = paths::home_dir(&app)?; + Ok(crate::integrations::probe::test_connection(&home, &harness)) + }) + .await } diff --git a/src-tauri/src/desktop/windows.rs b/src-tauri/src/desktop/windows.rs index c7da3e2..bf3fbde 100644 --- a/src-tauri/src/desktop/windows.rs +++ b/src-tauri/src/desktop/windows.rs @@ -34,20 +34,22 @@ pub(crate) fn install_menu(app: &tauri::App) -> tauri::Result<()> { application.insert(&settings, 1)?; } app.set_menu(menu)?; - app.on_menu_event(|app, event| match event.id().as_ref() { - "settings" => show_settings_window(app), - _ => {} + app.on_menu_event(|app, event| { + if event.id().as_ref() == "settings" { + if let Err(error) = show_settings_window(app) { + eprintln!("Failed to open settings window: {error}"); + } + } }); Ok(()) } -pub(crate) fn show_settings_window(app: &AppHandle) { +pub(crate) fn show_settings_window(app: &AppHandle) -> tauri::Result<()> { if let Some(window) = app.get_webview_window("settings") { - let _ = window.show(); - let _ = window.set_focus(); - return; + window.show()?; + return window.set_focus(); } - let result = (|| -> tauri::Result<()> { + (|| -> tauri::Result<()> { let window = WebviewWindowBuilder::new( app, "settings", @@ -65,8 +67,5 @@ pub(crate) fn show_settings_window(app: &AppHandle) { restore_native_frame(&window)?; window.show()?; window.set_focus() - })(); - if let Err(error) = result { - eprintln!("Failed to open settings window: {error}"); - } + })() } diff --git a/src-tauri/src/integrations/config.rs b/src-tauri/src/integrations/config.rs index 3771136..1784181 100644 --- a/src-tauri/src/integrations/config.rs +++ b/src-tauri/src/integrations/config.rs @@ -1,5 +1,13 @@ use serde_json::{json, Map, Value}; -use std::{fs, io::Write, path::Path, process::Command, str::FromStr}; +use std::{ + fs, + io::{Read, Write}, + path::Path, + process::{Command, Output, Stdio}, + str::FromStr, + thread, + time::{Duration, Instant}, +}; use toml_edit::{value, Array, DocumentMut}; use crate::SKILL_NAME; @@ -31,17 +39,43 @@ pub(crate) fn document_has_codex_mcp(document: &DocumentMut) -> bool { }) } +struct ConfigLock(fs::File); +impl Drop for ConfigLock { + fn drop(&mut self) { + let _ = fs2::FileExt::unlock(&self.0); + } +} +fn lock_config(path: &Path) -> Result { + use fs2::FileExt; + let parent = path.parent().ok_or("Configuration directory unavailable")?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let name = path + .file_name() + .ok_or("Configuration name unavailable")? + .to_string_lossy(); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(parent.join(format!(".{name}.sideglance.lock"))) + .map_err(|error| error.to_string())?; + file.try_lock_exclusive().map_err(|_| { + "Another Sideglance configuration edit is in progress; retry shortly".to_string() + })?; + Ok(ConfigLock(file)) +} + pub(crate) fn register_codex_mcp_at(path: &Path, executable: &Path) -> Result<(), String> { + let _lock = lock_config(path)?; let directory = path .parent() .ok_or("Codex configuration directory unavailable")?; fs::create_dir_all(directory) .map_err(|error| format!("Unable to create {}: {error}", directory.display()))?; - let contents = match fs::read_to_string(path) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(format!("Unable to read {}: {error}", path.display())), - }; + let original = read_config_bytes(path)?; + let contents = String::from_utf8(original.clone().unwrap_or_default()) + .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; let mut document = DocumentMut::from_str(&contents) .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; if let Some(servers) = document @@ -53,11 +87,21 @@ pub(crate) fn register_codex_mcp_at(path: &Path, executable: &Path) -> Result<() document["mcp_servers"][SKILL_NAME]["command"] = value(executable.to_string_lossy().to_string()); document["mcp_servers"][SKILL_NAME]["args"] = value(Array::from_iter(["--mcp"])); - save_toml_config(path, &document) + document["mcp_servers"][SKILL_NAME]["enabled"] = value(true); + if let Some(server) = document["mcp_servers"][SKILL_NAME].as_table_like_mut() { + server.remove("disabled"); + server.remove("url"); + server.remove("bearer_token_env_var"); + } + save_toml_config(path, &document, original.as_deref()) } -fn save_toml_config(path: &Path, document: &DocumentMut) -> Result<(), String> { - save_config(path, document.to_string().as_bytes()) +fn save_toml_config( + path: &Path, + document: &DocumentMut, + original: Option<&[u8]>, +) -> Result<(), String> { + save_config(path, document.to_string().as_bytes(), original) } pub(crate) fn register_codex_mcp(path: &Path, executable: &Path) -> Result<(), String> { @@ -65,12 +109,14 @@ pub(crate) fn register_codex_mcp(path: &Path, executable: &Path) -> Result<(), S } pub(crate) fn unregister_codex_mcp_at(path: &Path) -> Result<(), String> { - let contents = match fs::read_to_string(path) { - Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(format!("Unable to read {}: {error}", path.display())), + let _lock = lock_config(path)?; + let original = read_config_bytes(path)?; + let Some(bytes) = original.as_ref() else { + return Ok(()); }; - let mut document = DocumentMut::from_str(&contents) + let contents = std::str::from_utf8(bytes) + .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; + let mut document = DocumentMut::from_str(contents) .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; let removed = document .as_table_mut() @@ -84,7 +130,7 @@ pub(crate) fn unregister_codex_mcp_at(path: &Path) -> Result<(), String> { .is_some_and(|servers| servers.remove(SKILL_NAME).is_some()) }); if removed { - save_toml_config(path, &document)?; + save_toml_config(path, &document, original.as_deref())?; } Ok(()) } @@ -132,13 +178,41 @@ fn existing_nested_object_mut<'a>( } } -fn save_json_config(path: &Path, value: &Value) -> Result<(), String> { +fn save_json_config(path: &Path, value: &Value, original: Option<&[u8]>) -> Result<(), String> { let contents = serde_json::to_vec_pretty(value) .map_err(|error| format!("Unable to encode {}: {error}", path.display()))?; - save_config(path, &contents) + save_config(path, &contents, original) } -fn save_config(path: &Path, contents: &[u8]) -> Result<(), String> { +fn read_config_bytes(path: &Path) -> Result>, String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(format!( + "{} must be a regular configuration file", + path.display() + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Unable to inspect {}: {error}", path.display())), + } + fs::read(path) + .map(Some) + .map_err(|error| format!("Unable to read {}: {error}", path.display())) +} + +fn read_json_snapshot(path: &Path) -> Result<(Value, Option>), String> { + let original = read_config_bytes(path)?; + let value = match original.as_deref() { + None => json!({}), + Some(bytes) if bytes.iter().all(u8::is_ascii_whitespace) => json!({}), + Some(bytes) => serde_json::from_slice(bytes) + .map_err(|error| format!("Unable to read {}: {error}", path.display()))?, + }; + Ok((value, original)) +} + +fn save_config(path: &Path, contents: &[u8], original: Option<&[u8]>) -> Result<(), String> { let directory = path.parent().ok_or("Configuration directory unavailable")?; fs::create_dir_all(directory) .map_err(|error| format!("Unable to create {}: {error}", directory.display()))?; @@ -168,6 +242,14 @@ fn save_config(path: &Path, contents: &[u8]) -> Result<(), String> { .as_file() .sync_all() .map_err(|error| format!("Unable to sync {}: {error}", path.display()))?; + // Detect edits since the transaction began. External applications do not + // share our lock, so a small check-to-rename race remains unavoidable. + if read_config_bytes(path)?.as_deref() != original { + return Err(format!( + "{} changed while Sideglance was editing it. Retry the operation.", + path.display() + )); + } temporary .persist(path) .map_err(|error| format!("Unable to save {}: {error}", path.display()))?; @@ -175,18 +257,20 @@ fn save_config(path: &Path, contents: &[u8]) -> Result<(), String> { } pub(crate) fn register_standard_json_mcp(path: &Path, executable: &Path) -> Result<(), String> { - let mut config = read_json_config(path)?; + let _lock = lock_config(path)?; + let (mut config, original) = read_json_snapshot(path)?; let servers = nested_object_mut(&mut config, &["mcpServers"], &path.display().to_string())?; servers.remove("ctx-ppteer"); servers.insert( SKILL_NAME.into(), json!({ "command": executable, "args": ["--mcp"] }), ); - save_json_config(path, &config) + save_json_config(path, &config, original.as_deref()) } pub(crate) fn register_opencode_mcp(path: &Path, executable: &Path) -> Result<(), String> { - let mut config = read_json_config(path)?; + let _lock = lock_config(path)?; + let (mut config, original) = read_json_snapshot(path)?; let servers = nested_object_mut( &mut config, &["mcp", "servers"], @@ -197,15 +281,16 @@ pub(crate) fn register_opencode_mcp(path: &Path, executable: &Path) -> Result<() SKILL_NAME.into(), json!({ "type": "local", "command": [executable, "--mcp"], "codemode": false }), ); - save_json_config(path, &config) + save_json_config(path, &config, original.as_deref()) } pub(crate) fn unregister_json_mcp(path: &Path, keys: &[&str]) -> Result<(), String> { - let mut config = read_json_config(path)?; + let _lock = lock_config(path)?; + let (mut config, original) = read_json_snapshot(path)?; let removed = existing_nested_object_mut(&mut config, keys) .is_some_and(|servers| servers.remove(SKILL_NAME).is_some()); if removed { - save_json_config(path, &config)?; + save_json_config(path, &config, original.as_deref())?; } Ok(()) } @@ -232,23 +317,66 @@ pub(crate) fn command_exists(command: &str) -> bool { }) } +// Temporary output files avoid pipe deadlocks when a CLI emits more than a +// pipe buffer. Only a bounded stderr excerpt reaches the user. +fn run_bounded(command: &mut Command, timeout: Duration) -> std::io::Result { + let mut stdout = tempfile::tempfile()?; + let mut stderr = tempfile::tempfile()?; + let mut child = command + .stdin(Stdio::null()) + .stdout(stdout.try_clone()?) + .stderr(stderr.try_clone()?) + .spawn()?; + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)), + result => { + let error = result.err().unwrap_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "Claude Code timed out; retry after checking its installation", + ) + }); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + } + }; + use std::io::{Seek, SeekFrom}; + stdout.seek(SeekFrom::Start(0))?; + stderr.seek(SeekFrom::Start(0))?; + let mut output = Output { + status, + stdout: Vec::new(), + stderr: Vec::new(), + }; + stdout.take(64 * 1024).read_to_end(&mut output.stdout)?; + stderr.take(64 * 1024).read_to_end(&mut output.stderr)?; + Ok(output) +} + pub(crate) fn install_claude_mcp(executable: &Path, config_path: &Path) -> Result { - let output = match Command::new("claude") - .args(["mcp", "add", SKILL_NAME, "--scope", "user", "--"]) - .arg(executable) - .arg("--mcp") - .output() - { + let output = match run_bounded( + Command::new("claude") + .args(["mcp", "add", SKILL_NAME, "--scope", "user", "--"]) + .arg(executable) + .arg("--mcp"), + Duration::from_secs(30), + ) { Ok(output) => output, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(format!("Unable to run Claude Code: {error}")), }; if output.status.success() { - let mut config = read_json_config(config_path)?; + let _lock = lock_config(config_path)?; + let (mut config, original) = read_json_snapshot(config_path)?; if existing_nested_object_mut(&mut config, &["mcpServers"]) .is_some_and(|servers| servers.remove("ctx-ppteer").is_some()) { - save_json_config(config_path, &config)?; + save_json_config(config_path, &config, original.as_deref())?; } Ok(true) } else { @@ -260,10 +388,10 @@ pub(crate) fn install_claude_mcp(executable: &Path, config_path: &Path) -> Resul } pub(crate) fn uninstall_claude_mcp() -> Result { - let output = match Command::new("claude") - .args(["mcp", "remove", SKILL_NAME, "--scope", "user"]) - .output() - { + let output = match run_bounded( + Command::new("claude").args(["mcp", "remove", SKILL_NAME, "--scope", "user"]), + Duration::from_secs(30), + ) { Ok(output) => output, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(error) => return Err(format!("Unable to run Claude Code: {error}")), @@ -303,7 +431,7 @@ mod tests { let servers = nested_object_mut(&mut config, &keys, "test").unwrap(); servers.insert("ctx-ppteer".into(), json!({"command":"old"})); servers.insert("other".into(), json!({"command":"other"})); - save_json_config(&path, &config).unwrap(); + save_json_config(&path, &config, read_config_bytes(&path).unwrap().as_deref()).unwrap(); if keys.len() == 1 { register_standard_json_mcp(&path, Path::new("sideglance")).unwrap(); } else { @@ -316,6 +444,7 @@ mod tests { } } + #[cfg(unix)] #[test] fn mcp_changes_preserve_existing_config_permissions() { use std::os::unix::fs::PermissionsExt; @@ -520,4 +649,84 @@ mod tests { assert!(config["mcp"]["servers"]["existing"].is_object()); assert_eq!(config["mcp"]["servers"][SKILL_NAME]["type"], "local"); } + #[test] + fn detects_external_config_edit_before_replacement() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp.json"); + fs::write(&path, r#"{"before":true}"#).unwrap(); + let original = read_config_bytes(&path).unwrap(); + let newer = br#"{"external":"keep me"}"#; + fs::write(&path, newer).unwrap(); + assert!(save_config(&path, b"{}", original.as_deref()) + .unwrap_err() + .contains("changed")); + assert_eq!(fs::read(&path).unwrap(), newer); + fs::remove_file(&path).unwrap(); + assert!(save_config(&path, b"{}", original.as_deref()).is_err()); + assert!(!path.exists()); + } + + #[test] + fn cooperating_config_edits_do_not_overlap() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let lock = lock_config(&path).unwrap(); + assert!(register_codex_mcp_at(&path, Path::new("sideglance")).is_err()); + drop(lock); + register_codex_mcp_at(&path, Path::new("sideglance")).unwrap(); + } + + #[cfg(unix)] + #[test] + fn bounded_process_captures_large_output_and_reaps_timeout() { + let output = run_bounded( + Command::new("sh").args(["-c", "head -c 100000 /dev/zero >&2"]), + Duration::from_secs(3), + ) + .unwrap(); + assert!(output.status.success()); + assert_eq!(output.stderr.len(), 64 * 1024); + let dir = tempfile::tempdir().unwrap(); + let pid_path = dir.path().join("pid"); + let start = Instant::now(); + let error = run_bounded( + Command::new("sh") + .args(["-c", "echo $$ > \"$1\"; exec sleep 30", "sh"]) + .arg(&pid_path), + Duration::from_millis(100), + ) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert!(start.elapsed() < Duration::from_secs(3)); + let pid = fs::read_to_string(pid_path).unwrap(); + assert!(!Command::new("kill") + .args(["-0", pid.trim()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success()); + } +} + +#[cfg(test)] +mod repair_tests { + use super::*; + #[test] + fn repair_enables_local_codex_and_removes_conflicting_remote_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path,"[mcp_servers.sideglance]\nenabled=false\ndisabled=true\nurl='https://example.com/mcp'\n").unwrap(); + register_codex_mcp_at(&path, Path::new("sideglance")).unwrap(); + let doc = fs::read_to_string(&path) + .unwrap() + .parse::() + .unwrap(); + assert_eq!( + doc["mcp_servers"][SKILL_NAME]["enabled"].as_bool(), + Some(true) + ); + assert!(doc["mcp_servers"][SKILL_NAME].get("url").is_none()); + assert!(doc["mcp_servers"][SKILL_NAME].get("disabled").is_none()); + } } diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs index b7744d2..afceb02 100644 --- a/src-tauri/src/integrations/mod.rs +++ b/src-tauri/src/integrations/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod probe; pub mod registry; pub mod skills; pub mod status; diff --git a/src-tauri/src/integrations/probe.rs b/src-tauri/src/integrations/probe.rs new file mode 100644 index 0000000..f9b0b23 --- /dev/null +++ b/src-tauri/src/integrations/probe.rs @@ -0,0 +1,336 @@ +//! Explicit, read-only MCP connectivity checks. No status write is used as a probe. +use super::registry::{additional_codex_directory, config_path, descriptor, ConfigAdapter}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::Duration, +}; + +#[derive(Serialize)] +pub struct ConnectionTest { + pub status: String, + pub message: String, +} + +pub(crate) fn configuration(home: &Path, harness: &str) -> Result<(String, Vec), String> { + let (path, adapter) = if let Some(directory) = additional_codex_directory(home, harness) { + (directory.join("config.toml"), ConfigAdapter::CodexToml) + } else { + let agent = descriptor(harness).ok_or("Unsupported agent")?; + (config_path(home, agent), agent.adapter) + }; + let mut bytes = Vec::new(); + fs::File::open(&path) + .map_err(|_| "Agent configuration could not be read")? + .take(1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Agent configuration could not be read")?; + if bytes.len() > 1024 * 1024 { + return Err("Agent configuration exceeds 1 MiB".into()); + } + let (command, args) = if adapter == ConfigAdapter::CodexToml { + let text = std::str::from_utf8(&bytes).map_err(|_| "Agent configuration is not UTF-8")?; + let doc = text + .parse::() + .map_err(|_| "Agent TOML configuration is invalid")?; + let server = doc + .get("mcp_servers") + .and_then(|servers| servers.get("sideglance")) + .ok_or("Sideglance MCP is not configured")?; + if server.get("enabled").and_then(|v| v.as_bool()) == Some(false) + || server.get("disabled").and_then(|v| v.as_bool()) == Some(true) + { + return Err("Sideglance MCP is disabled".into()); + } + let command = server + .get("command") + .and_then(|v| v.as_str()) + .ok_or("MCP executable is missing")? + .to_string(); + let args = server + .get("args") + .and_then(|v| v.as_array()) + .ok_or("MCP arguments are missing")? + .iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or("MCP arguments must be strings") + }) + .collect::, _>>()?; + (command, args) + } else { + let config: Value = + serde_json::from_slice(&bytes).map_err(|_| "Agent JSON configuration is invalid")?; + let server = if adapter == ConfigAdapter::OpenCodeJson { + config.pointer("/mcp/servers/sideglance") + } else { + config.pointer("/mcpServers/sideglance") + } + .ok_or("Sideglance MCP is not configured")?; + if server.get("enabled").and_then(Value::as_bool) == Some(false) + || server.get("disabled").and_then(Value::as_bool) == Some(true) + { + return Err("Sideglance MCP is disabled".into()); + } + if adapter == ConfigAdapter::OpenCodeJson { + let command = server + .get("command") + .and_then(Value::as_array) + .ok_or("MCP command is missing")?; + let mut parts = command + .iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or("MCP command must contain strings") + }) + .collect::, _>>()?; + if parts.is_empty() { + return Err("MCP executable is missing".into()); + } + (parts.remove(0), parts) + } else { + let command = server + .get("command") + .and_then(Value::as_str) + .ok_or("MCP executable is missing")? + .to_string(); + let args = server + .get("args") + .and_then(Value::as_array) + .ok_or("MCP arguments are missing")? + .iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or("MCP arguments must be strings") + }) + .collect::, _>>()?; + (command, args) + } + }; + if args != ["--mcp"] { + return Err( + "Sideglance requires exactly the --mcp argument. Repair the configuration.".into(), + ); + } + let executable = resolve_executable(&command) + .ok_or("MCP executable was not found or is not executable. Repair the configuration.")?; + Ok((executable.to_string_lossy().into_owned(), args)) +} +fn resolve_executable(command: &str) -> Option { + let path = PathBuf::from(command); + let candidates = if path.is_absolute() { + vec![path] + } else if path.components().count() == 1 { + std::env::split_paths(&std::env::var_os("PATH")?) + .flat_map(|dir| { + let mut paths = vec![dir.join(command)]; + if cfg!(windows) { + paths.push(dir.join(format!("{command}.exe"))); + } + paths + }) + .collect() + } else { + return None; + }; + candidates.into_iter().find(|path| { + let Ok(meta) = fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } + }) +} + +pub(crate) fn test_connection(home: &Path, harness: &str) -> ConnectionTest { + let (executable, args) = match configuration(home, harness) { + Ok(value) => value, + Err(message) => { + return ConnectionTest { + status: "invalid".into(), + message, + } + } + }; + match handshake(Command::new(executable).args(args), Duration::from_secs(5)) { + Ok(()) => ConnectionTest { status: "connected".into(), message: "A new connection responded with Sideglance tools. Restart existing agent sessions after an update; this test does not reload them.".into() }, + Err(message) => ConnectionTest { status: "error".into(), message }, + } +} + +fn handshake(command: &mut Command, timeout: Duration) -> Result<(), String> { + let mut child_command = tokio::process::Command::new(command.get_program()); + child_command.args(command.get_args()); + for (name, value) in command.get_envs() { + if let Some(value) = value { + child_command.env(name, value); + } else { + child_command.env_remove(name); + } + } + if let Some(directory) = command.get_current_dir() { + child_command.current_dir(directory); + } + tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?.block_on(async { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + let mut child = child_command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true).spawn().map_err(|error| format!("Unable to start MCP: {error}"))?; + let mut input = child.stdin.take().unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap().take(64 * 1024)).lines(); + async fn response(output: &mut tokio::io::Lines, id: u64) -> Result { + while let Some(line) = output.next_line().await.map_err(|e| e.to_string())? { + let Ok(value) = serde_json::from_str::(&line) else { continue; }; + if value.get("id").and_then(Value::as_u64) != Some(id) { continue; } + if value.get("error").is_some() { return Err("MCP rejected the protocol request".into()); } + return value.get("result").cloned().ok_or_else(|| "MCP returned an invalid response".into()); + } + Err("MCP output ended or exceeded 64 KiB before returning a valid response".into()) + } + let result = tokio::time::timeout(timeout, async { + let initialize = json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sideglance-connection-test","version":"1"}}}); + input.write_all(format!("{initialize}\n").as_bytes()).await.map_err(|e| e.to_string())?; + let initialized = response(&mut output,1).await?; + if initialized.get("protocolVersion").and_then(Value::as_str) != Some("2024-11-05") { return Err("MCP negotiated an unsupported protocol version".into()); } + let initialized = json!({"jsonrpc":"2.0","method":"notifications/initialized"}); + let list = json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}); + input.write_all(format!("{initialized}\n{list}\n").as_bytes()).await.map_err(|e| e.to_string())?; + let listed = response(&mut output,2).await?; + let tools = listed.get("tools").and_then(Value::as_array).ok_or("MCP did not return tools")?; + if !["read_status","write_status"].iter().all(|name| tools.iter().any(|tool| tool.get("name").and_then(Value::as_str)==Some(name))) { return Err("Connected server does not provide Sideglance tools".into()); } + Ok(()) + }).await.unwrap_or_else(|_| Err("MCP did not respond before the connection deadline".into())); + drop(input); + drop(output); + let _ = child.kill().await; + let _ = child.wait().await; + result + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn configuration_reports_disabled_missing_and_wrong_arguments() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join(".codex/config.toml"); + fs::create_dir_all(config.parent().unwrap()).unwrap(); + for (contents, expected) in [ + ("[mcp_servers.sideglance]\nenabled=false", "disabled"), + ( + "[mcp_servers.sideglance]\ncommand='missing-sideglance'\nargs=['--mcp']", + "not found", + ), + ( + "[mcp_servers.sideglance]\ncommand='sideglance'\nargs=[]", + "--mcp", + ), + ] { + fs::write(&config, contents).unwrap(); + assert!(configuration(dir.path(), "codex") + .unwrap_err() + .contains(expected)); + } + } + #[cfg(unix)] + #[test] + fn actual_stdio_connection_is_read_only_and_timeout_is_bounded() { + let exe = Path::new("/tmp/sideglance-legacy-remediation-mcp"); + if exe.is_file() { + let home = tempfile::tempdir().unwrap(); + handshake( + Command::new(exe).arg("--mcp").env("HOME", home.path()), + Duration::from_secs(2), + ) + .unwrap(); + assert!(!home + .path() + .join(".local/share/sideglance/status.json") + .exists()); + } + let started = std::time::Instant::now(); + assert!(handshake(Command::new("sleep").arg("3"), Duration::from_millis(40)).is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + } +} + +#[cfg(test)] +mod protocol_tests { + use super::*; + #[test] + fn mock_server() { + use std::io::{BufRead, Write}; + let Ok(mode) = std::env::var("SIDEGLANCE_PROBE_TEST") else { + return; + }; + let mut lines = std::io::stdin().lock().lines(); + assert!(lines.next().is_some()); + let version = if mode == "wrong-version" { + "unsupported" + } else { + "2024-11-05" + }; + println!( + "{}", + json!({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":version}}) + ); + std::io::stdout().flush().unwrap(); + if mode == "wrong-version" { + return; + } + assert!(lines.next().is_some()); + assert!(lines.next().is_some()); + let tools = if mode == "missing-tools" { + vec![] + } else { + vec![ + json!({"name":"read_status"}), + json!({"name":"write_status"}), + ] + }; + println!( + "{}", + json!({"jsonrpc":"2.0","id":2,"result":{"tools":tools}}) + ); + std::io::stdout().flush().unwrap(); + } + #[test] + fn handshake_checks_negotiation_and_required_tools() { + for (mode, expected) in [ + ("ok", None), + ("wrong-version", Some("unsupported")), + ("missing-tools", Some("does not provide")), + ] { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "integrations::probe::protocol_tests::mock_server", + "--nocapture", + ]) + .env("SIDEGLANCE_PROBE_TEST", mode); + let result = handshake(&mut command, Duration::from_secs(3)); + if let Some(expected) = expected { + assert!(result.unwrap_err().contains(expected)); + } else { + result.unwrap(); + } + } + } +} diff --git a/src-tauri/src/integrations/skills.rs b/src-tauri/src/integrations/skills.rs index 8b3927d..af25c59 100644 --- a/src-tauri/src/integrations/skills.rs +++ b/src-tauri/src/integrations/skills.rs @@ -41,6 +41,7 @@ pub(crate) struct AgentInstallation { pub name: String, pub skill_installed: bool, pub mcp_registered: bool, + pub configuration_valid: bool, pub available: bool, pub config_path: String, pub error: Option, @@ -73,11 +74,20 @@ pub(crate) fn copy_directory(source: &Path, destination: &Path) -> Result<(), St let entry = entry.map_err(|e| e.to_string())?; let source_path = entry.path(); let destination_path = destination.join(entry.file_name()); - if entry.file_type().map_err(|e| e.to_string())?.is_dir() { + let file_type = entry.file_type().map_err(|e| e.to_string())?; + if file_type.is_symlink() { + return Err(format!( + "Skill source contains a symbolic link: {}", + source_path.display() + )); + } + if file_type.is_dir() { copy_directory(&source_path, &destination_path)?; - } else { + } else if file_type.is_file() { fs::copy(&source_path, &destination_path) .map_err(|e| format!("Unable to copy {}: {e}", source_path.display()))?; + } else { + return Err(format!("Unsupported skill file: {}", source_path.display())); } } Ok(()) @@ -122,20 +132,74 @@ pub(crate) fn skill_hash(path: &Path) -> Result { } pub(crate) fn install_at(source: &Path, destination: &Path) -> Result { - if destination.join("SKILL.md").is_file() { - fs::remove_dir_all(destination) - .map_err(|error| format!("Unable to replace {}: {error}", destination.display()))?; - copy_directory(source, destination)?; - retire_legacy_skill(destination)?; - return Ok(true); + install_with_rename(source, destination, |from, to| fs::rename(from, to)) +} + +fn install_with_rename( + source: &Path, + destination: &Path, + mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, +) -> Result { + use fs2::FileExt; + let source_type = fs::symlink_metadata(source).map_err(|error| error.to_string())?; + if !source_type.is_dir() || !source.join("SKILL.md").is_file() { + return Err("The skill source must be a directory containing SKILL.md".into()); } - if destination.exists() { + let parent = destination + .parent() + .ok_or("Skill destination has no parent")?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let name = destination + .file_name() + .ok_or("Skill destination has no name")? + .to_string_lossy(); + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(parent.join(format!(".{name}.install.lock"))) + .map_err(|error| error.to_string())?; + lock.try_lock_exclusive() + .map_err(|_| "Another skill installation is in progress; retry shortly".to_string())?; + let existing = match fs::symlink_metadata(destination) { + Ok(metadata) => { + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || !destination.join("SKILL.md").is_file() + { + return Err(format!("{} already exists and is not a regular skill directory. Remove or rename it before installing.", destination.display())); + } + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error.to_string()), + }; + let stage = tempfile::Builder::new() + .prefix(&format!(".{name}.staging-")) + .tempdir_in(parent) + .map_err(|error| error.to_string())?; + copy_directory(source, stage.path())?; + if skill_hash(source)? != skill_hash(stage.path())? { + return Err("Skill source changed during installation; retry the operation".into()); + } + let backup = parent.join(format!(".{name}.backup-{}", uuid::Uuid::new_v4())); + if existing { + rename(destination, &backup) + .map_err(|error| format!("Unable to back up {}: {error}", destination.display()))?; + } + if let Err(error) = rename(stage.path(), destination) { + if existing { + rename(&backup, destination).map_err(|restore_error| format!( + "Installation failed: {error}. Unable to restore previous skill: {restore_error}. Recover it from {}", backup.display()))?; + } return Err(format!( - "{} already exists. Remove or rename it before installing the skill.", + "Unable to install {}: {error}. Previous installation preserved.", destination.display() )); } - copy_directory(source, destination)?; + // Backups are deliberately retained. A crash between the two renames leaves + // the old skill in the named backup, never deleted. Recovery is a rename. retire_legacy_skill(destination)?; Ok(true) } @@ -231,4 +295,84 @@ mod tests { fs::rename(skill.join("nested"), skill.join("renamed")).unwrap(); assert_ne!(original, skill_hash(&skill).unwrap()); } + #[test] + fn replacements_keep_customized_backup_and_rollback_failed_swap() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source"); + let destination = dir.path().join("sideglance"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), "new").unwrap(); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("SKILL.md"), "custom").unwrap(); + fs::write(destination.join("notes.txt"), "keep").unwrap(); + let mut calls = 0; + let error = install_with_rename(&source, &destination, |from, to| { + calls += 1; + if calls == 2 { + return Err(std::io::Error::other("injected rename failure")); + } + fs::rename(from, to) + }) + .unwrap_err(); + assert!(error.contains("Previous installation preserved")); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).unwrap(), + "custom" + ); + assert_eq!( + fs::read_to_string(destination.join("notes.txt")).unwrap(), + "keep" + ); + install_at(&source, &destination).unwrap(); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).unwrap(), + "new" + ); + let backup = fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sideglance.backup-") + }) + .unwrap() + .path(); + assert_eq!( + fs::read_to_string(backup.join("notes.txt")).unwrap(), + "keep" + ); + assert_eq!( + fs::read_to_string(backup.join("SKILL.md")).unwrap(), + "custom" + ); + } + + #[cfg(unix)] + #[test] + fn unsafe_copy_and_symlink_destination_preserve_originals() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source"); + let destination = dir.path().join("sideglance"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("SKILL.md"), "new").unwrap(); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("SKILL.md"), "original").unwrap(); + symlink("missing", source.join("unsafe")).unwrap(); + assert!(install_at(&source, &destination).is_err()); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).unwrap(), + "original" + ); + fs::remove_file(source.join("unsafe")).unwrap(); + let linked_destination = dir.path().join("linked"); + symlink(&destination, &linked_destination).unwrap(); + assert!(install_at(&source, &linked_destination).is_err()); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).unwrap(), + "original" + ); + } } diff --git a/src-tauri/src/integrations/status.rs b/src-tauri/src/integrations/status.rs index a6fe6a0..423d226 100644 --- a/src-tauri/src/integrations/status.rs +++ b/src-tauri/src/integrations/status.rs @@ -77,6 +77,7 @@ pub(crate) fn additional_codex_agents(home: &Path) -> Vec { .join("SKILL.md") .is_file(), mcp_registered, + configuration_valid: false, available: true, config_path: config_path.display().to_string(), error, @@ -140,7 +141,7 @@ pub(crate) fn integration_status(app: &AppHandle) -> Result Result>(); - let additional_agents = additional_codex_agents(&home); + let mut additional_agents = additional_codex_agents(&home); + for agent in agents.iter_mut().chain(additional_agents.iter_mut()) { + let validation = super::probe::configuration(&home, &agent.id); + agent.configuration_valid = validation.is_ok(); + if agent.mcp_registered && agent.error.is_none() { + agent.error = validation.err(); + } + } Ok(SkillInstallation { installed: destination.join("SKILL.md").is_file(), mcp_registered: !agents.is_empty() && agents.iter().all(|agent| agent.mcp_registered), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2dc37d..54d1041 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,10 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ desktop::commands::get_snapshot, + desktop::commands::test_mcp_connection, + desktop::commands::get_recently_removed, + desktop::commands::restore_removed, + desktop::commands::preview_retention, desktop::commands::dismiss_point, desktop::commands::set_point_pinned, desktop::commands::restore_point, diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index b4ca841..2a97e23 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -79,9 +79,9 @@ fn map_status_error(error: StatusError) -> McpError { match error { StatusError::InvalidInput(message) => McpError::invalid_params(message, None), StatusError::Conflict(message) => McpError::invalid_params(message, None), - StatusError::InvalidDocument(message) | StatusError::Io(message) => { - McpError::internal_error(message, None) - } + StatusError::InvalidDocument(message) + | StatusError::Io(message) + | StatusError::LockTimeout(message) => McpError::internal_error(message, None), } } @@ -102,18 +102,22 @@ impl StatusServer { &self, Parameters(input): Parameters, ) -> Result { - let path = paths::status_path() - .map_err(|error| McpError::internal_error(error.to_string(), None))?; - let entries = StatusStore::new(path) - .read_project(&input.folder_name, &input.session_id) - .map_err(map_status_error)?; - let points: Vec = entries - .into_iter() - .flat_map(|entry| entry.points) - .map(Into::into) - .collect(); - serde_json::to_string(&points) - .map_err(|error| McpError::internal_error(error.to_string(), None)) + tokio::task::spawn_blocking(move || { + let path = paths::status_path() + .map_err(|error| McpError::internal_error(error.to_string(), None))?; + let entries = StatusStore::new(path) + .read_project(&input.folder_name, &input.session_id) + .map_err(map_status_error)?; + let points: Vec = entries + .into_iter() + .flat_map(|entry| entry.points) + .map(Into::into) + .collect(); + serde_json::to_string(&points) + .map_err(|error| McpError::internal_error(error.to_string(), None)) + }) + .await + .map_err(|error| McpError::internal_error(format!("Status worker failed: {error}"), None))? } #[tool( @@ -123,19 +127,24 @@ impl StatusServer { &self, Parameters(input): Parameters, ) -> Result { - let path = paths::status_path().map_err(|error| McpError::internal_error(error, None))?; - let (_, points) = StatusStore::new(path) - .upsert_with_repository_result( - input.folder_name.clone(), - input.session_id, - input.branch, - input.points.into_iter().map(Into::into).collect(), - input.repository, - ) - .map_err(|error| map_status_error(error))?; - let points: Vec = points.into_iter().map(Into::into).collect(); - serde_json::to_string(&points) - .map_err(|error| McpError::internal_error(error.to_string(), None)) + tokio::task::spawn_blocking(move || { + let path = + paths::status_path().map_err(|error| McpError::internal_error(error, None))?; + let (_, points) = StatusStore::new(path) + .upsert_with_repository_result( + input.folder_name.clone(), + input.session_id, + input.branch, + input.points.into_iter().map(Into::into).collect(), + input.repository, + ) + .map_err(map_status_error)?; + let points: Vec = points.into_iter().map(Into::into).collect(); + serde_json::to_string(&points) + .map_err(|error| McpError::internal_error(error.to_string(), None)) + }) + .await + .map_err(|error| McpError::internal_error(format!("Status worker failed: {error}"), None))? } } @@ -143,7 +152,9 @@ impl StatusServer { impl ServerHandler for StatusServer {} pub fn run() -> Result<(), String> { - tokio::runtime::Runtime::new() + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .map_err(|error| error.to_string())? .block_on(async { StatusServer::new() diff --git a/src-tauri/src/preferences/mod.rs b/src-tauri/src/preferences/mod.rs index 1662aa9..b34f0c2 100644 --- a/src-tauri/src/preferences/mod.rs +++ b/src-tauri/src/preferences/mod.rs @@ -194,35 +194,60 @@ fn has_removed_window_preferences(contents: &[u8]) -> bool { .is_some_and(|settings| settings.contains_key("window")) } +fn load_recoverable(path: &Path) -> Result<(Settings, Option), String> { + let contents = match fs::read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let defaults = Settings::default(); + persist(path, &defaults)?; + return Ok((defaults, None)); + } + Err(error) => return Err(format!("Unable to read {}: {error}", path.display())), + }; + let parsed = serde_json::from_slice::(&contents) + .map_err(|error| error.to_string()) + .and_then(|settings| { + validate(&settings)?; + Ok(settings) + }); + match parsed { + Ok(settings) => { + if has_removed_window_preferences(&contents) { + persist(path, &settings)?; + } + Ok((settings, None)) + } + Err(error) => { + let backup = path.with_extension(format!("invalid-{}.backup", uuid::Uuid::new_v4())); + let parent = path.parent().ok_or("Settings directory unavailable")?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + std::io::Write::write_all(&mut temp, &contents).map_err(|e| e.to_string())?; + temp.as_file().sync_all().map_err(|e| e.to_string())?; + temp.persist_noclobber(&backup).map_err(|e| e.to_string())?; + let defaults = Settings::default(); + persist(path, &defaults)?; + Ok((defaults, Some(format!("Settings could not be loaded: {error}. Defaults are in use. Your original settings are saved at {}", backup.display())))) + } + } +} + +type SettingsPublisher = Arc; + pub struct SettingsOwner { path: PathBuf, settings: Mutex, - publisher: Option>, + publisher: Option, + recovery_warning: Option, } impl SettingsOwner { pub fn load(app: &AppHandle) -> Result, String> { let path = paths::settings_path(app)?; - let settings = match fs::read(&path) { - Ok(contents) => { - let settings: Settings = serde_json::from_slice(&contents) - .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; - validate(&settings)?; - if has_removed_window_preferences(&contents) { - persist(&path, &settings)?; - } - settings - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let settings = Settings::default(); - persist(&path, &settings)?; - settings - } - Err(error) => return Err(format!("Unable to read {}: {error}", path.display())), - }; + let (settings, recovery_warning) = load_recoverable(&path)?; Ok(Arc::new(Self { path, settings: Mutex::new(settings), + recovery_warning, publisher: Some(Arc::new({ let app = app.clone(); move |settings| { @@ -234,23 +259,11 @@ impl SettingsOwner { #[cfg(test)] pub fn from_path(path: PathBuf) -> Result, String> { - let settings = match fs::read(&path) { - Ok(contents) => { - let settings: Settings = serde_json::from_slice(&contents) - .map_err(|error| format!("Unable to read {}: {error}", path.display()))?; - validate(&settings)?; - settings - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let settings = Settings::default(); - persist(&path, &settings)?; - settings - } - Err(error) => return Err(format!("Unable to read {}: {error}", path.display())), - }; + let (settings, recovery_warning) = load_recoverable(&path)?; Ok(Arc::new(Self { path, settings: Mutex::new(settings), + recovery_warning, publisher: None, })) } @@ -258,16 +271,21 @@ impl SettingsOwner { #[cfg(test)] pub fn from_path_with_publisher( path: PathBuf, - publisher: Arc, + publisher: SettingsPublisher, ) -> Result, String> { let owner = Self::from_path(path)?; Ok(Arc::new(Self { path: owner.path.clone(), settings: Mutex::new(owner.snapshot()), + recovery_warning: owner.recovery_warning(), publisher: Some(publisher), })) } + pub fn recovery_warning(&self) -> Option { + self.recovery_warning.clone() + } + pub fn snapshot(&self) -> Settings { self.settings.lock().unwrap().clone() } @@ -434,6 +452,7 @@ mod tests { let owner = SettingsOwner { path: directory.path().join("missing/settings.json"), settings: Mutex::new(before.clone()), + recovery_warning: None, publisher: None, }; fs::write(directory.path().join("missing"), "file").unwrap(); @@ -465,3 +484,40 @@ mod tests { assert_eq!(events.last().unwrap(), &owner.snapshot()); } } + +#[cfg(test)] +mod recovery_tests { + use super::*; + #[test] + fn invalid_settings_start_with_defaults_and_keep_original_bytes() { + for contents in [b"broken".as_slice(), br#"{"textScale":99}"#] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, contents).unwrap(); + let owner = SettingsOwner::from_path(path.clone()).unwrap(); + assert_eq!(owner.snapshot(), Settings::default()); + assert!(owner + .recovery_warning() + .unwrap() + .contains("original settings")); + let backup = fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .find(|e| e.path().extension().is_some_and(|x| x == "backup")) + .unwrap(); + assert_eq!(fs::read(backup.path()).unwrap(), contents); + assert!(SettingsOwner::from_path(path) + .unwrap() + .recovery_warning() + .is_none()); + } + } + #[test] + fn unreadable_path_is_not_overwritten_as_defaults() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + fs::create_dir(&path).unwrap(); + assert!(SettingsOwner::from_path(path.clone()).is_err()); + assert!(path.is_dir()); + } +} diff --git a/src-tauri/src/status/snapshot.rs b/src-tauri/src/status/snapshot.rs index 5b17c78..d57a04b 100644 --- a/src-tauri/src/status/snapshot.rs +++ b/src-tauri/src/status/snapshot.rs @@ -4,9 +4,9 @@ use std::{ path::PathBuf, sync::{Arc, Mutex}, }; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager}; -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Health { Waiting, @@ -14,7 +14,7 @@ pub enum Health { Stale, Error, } -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct Snapshot { pub path: Option, pub document: crate::status::domain::Document, @@ -22,6 +22,8 @@ pub struct Snapshot { pub health: Health, pub error: Option, pub modified_at: Option, + pub last_successful_read_at: Option, + pub recovery_warning: Option, } pub struct ViewerState { @@ -44,6 +46,8 @@ impl ViewerState { health: Health::Waiting, error: None, modified_at: None, + last_successful_read_at: None, + recovery_warning: None, }), coordinator: RefreshCoordinator::new(), watcher_error: Mutex::new(None), @@ -80,6 +84,12 @@ fn modified_at(path: &std::path::Path) -> Option { } fn snapshot_for(state: &ViewerState, previous: &Snapshot) -> Snapshot { let modified_at = modified_at(&state.path); + let warning = state + .watcher_error + .lock() + .unwrap() + .clone() + .or_else(|| state.retention_error.lock().unwrap().clone()); let stale = |health, error| Snapshot { path: Some(state.path.display().to_string()), document: previous.document.clone(), @@ -87,6 +97,8 @@ fn snapshot_for(state: &ViewerState, previous: &Snapshot) -> Snapshot { health, error, modified_at: modified_at.clone(), + last_successful_read_at: previous.last_successful_read_at.clone(), + recovery_warning: previous.recovery_warning.clone(), }; match std::fs::metadata(&state.path) { Ok(meta) if !meta.is_file() => stale( @@ -97,29 +109,37 @@ fn snapshot_for(state: &ViewerState, previous: &Snapshot) -> Snapshot { Health::Error, Some("The file is larger than the 1 MiB limit".into()), ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => stale(Health::Waiting, None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if previous.last_successful_read_at.is_some() { + stale( + Health::Stale, + Some( + "Status file is missing. Showing the last successfully read updates." + .into(), + ), + ) + } else if warning.is_some() { + stale(Health::Error, warning.clone()) + } else { + stale(Health::Waiting, None) + } + } Err(error) => stale(Health::Error, Some(format!("Unable to read file: {error}"))), Ok(_) => match state.store.read() { - Ok(document) => { - let warning = state - .watcher_error - .lock() - .unwrap() - .clone() - .or_else(|| state.retention_error.lock().unwrap().clone()); - Snapshot { - path: Some(state.path.display().to_string()), - document, - revision: previous.revision.saturating_add(1), - health: if warning.is_some() { - Health::Error - } else { - Health::Current - }, - error: warning, - modified_at, - } - } + Ok(document) => Snapshot { + path: Some(state.path.display().to_string()), + document, + revision: previous.revision.saturating_add(1), + health: if warning.is_some() { + Health::Error + } else { + Health::Current + }, + error: warning, + modified_at, + last_successful_read_at: Some(chrono::Utc::now().to_rfc3339()), + recovery_warning: previous.recovery_warning.clone(), + }, Err(error) => stale(Health::Stale, Some(error.to_string())), }, } @@ -129,29 +149,69 @@ pub fn update(app: &AppHandle, state: &ViewerState) { state.coordinator.refresh( || { let previous = state.snapshot.lock().unwrap().clone(); - snapshot_for(state, &previous) + let mut next = snapshot_for(state, &previous); + next.recovery_warning = app + .try_state::>() + .and_then(|owner| owner.recovery_warning()); + next }, |snapshot| { - *state.snapshot.lock().unwrap() = snapshot.clone(); - let _ = app.emit("source-update", snapshot); + if let Some(snapshot) = cache_snapshot(&state.snapshot, snapshot) { + let _ = app.emit("source-update", snapshot); + } }, ); } +// The refresh coordinator serializes this cache commit with publication. +fn cache_snapshot(slot: &Mutex, mut snapshot: Snapshot) -> Option { + let mut current = slot.lock().unwrap(); + let changed = meaningful_change(¤t, &snapshot); + snapshot.revision = if changed { + current.revision.saturating_add(1) + } else { + current.revision + }; + *current = snapshot.clone(); + changed.then_some(snapshot) +} + +// Read timestamps and filesystem metadata are cached without creating layout revisions. +fn meaningful_change(previous: &Snapshot, next: &Snapshot) -> bool { + previous.path != next.path + || previous.document != next.document + || previous.health != next.health + || previous.error != next.error + || previous.recovery_warning != next.recovery_warning +} + +fn set_warning(slot: &Mutex>, next: Option) -> bool { + let mut current = slot.lock().unwrap(); + if *current == next { + return false; + } + *current = next; + true +} + pub fn publish_watcher_error(app: &AppHandle, state: &ViewerState, message: impl Into) { - *state.watcher_error.lock().unwrap() = Some(message.into()); - update(app, state); + if set_warning(&state.watcher_error, Some(message.into())) { + update(app, state); + } } pub fn watcher_recovered(app: &AppHandle, state: &ViewerState) { - *state.watcher_error.lock().unwrap() = None; - update(app, state); + if set_warning(&state.watcher_error, None) { + update(app, state); + } } pub fn publish_retention_error(app: &AppHandle, state: &ViewerState, message: impl Into) { - *state.retention_error.lock().unwrap() = Some(message.into()); - update(app, state); + if set_warning(&state.retention_error, Some(message.into())) { + update(app, state); + } } pub fn retention_recovered(app: &AppHandle, state: &ViewerState) { - *state.retention_error.lock().unwrap() = None; - update(app, state); + if set_warning(&state.retention_error, None) { + update(app, state); + } } #[cfg(test)] @@ -170,6 +230,8 @@ mod tests { health: Health::Waiting, error: None, modified_at: None, + last_successful_read_at: None, + recovery_warning: None, })); let barrier = Arc::new(Barrier::new(3)); let published = Arc::new(Mutex::new(Vec::new())); @@ -243,3 +305,101 @@ mod tests { assert_eq!(second.error.as_deref(), Some("watch unavailable")); } } + +#[cfg(test)] +mod regression_tests { + use super::*; + + fn sample() -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().unwrap(); + let state = ViewerState::new(directory.path().join("status.json")); + std::fs::write(&state.path, r#"{"version":1,"entries":[{"folder_name":"project","session_id":"s","branch":"main","points":[{"text":"working"}],"updated_at":"2026-01-01T00:00:00Z"}]}"#).unwrap(); + (directory, state) + } + + #[test] + fn unchanged_reads_cache_fresh_read_times_without_meaningful_change() { + let (_directory, state) = sample(); + let first = snapshot_for(&state, &state.snapshot.lock().unwrap()); + assert!(first.last_successful_read_at.is_some()); + let second = snapshot_for(&state, &first); + assert!(!meaningful_change(&first, &second)); + let mut timestamp_only = second.clone(); + timestamp_only.modified_at = Some("different filesystem timestamp".into()); + timestamp_only.last_successful_read_at = Some("later successful read".into()); + assert!(!meaningful_change(&first, ×tamp_only)); + let mut warning = second.clone(); + warning.error = Some("watcher failed".into()); + warning.health = Health::Error; + assert!(meaningful_change(&first, &warning)); + warning = second.clone(); + warning.recovery_warning = Some("Settings recovered".into()); + assert!(meaningful_change(&first, &warning)); + } + + #[test] + fn disappearing_file_retains_last_good_document_with_visible_stale_warning() { + let (_directory, state) = sample(); + let first = snapshot_for(&state, &state.snapshot.lock().unwrap()); + std::fs::remove_file(&state.path).unwrap(); + let missing = snapshot_for(&state, &first); + assert_eq!(missing.health, Health::Stale); + assert!(missing.error.as_deref().unwrap().contains("missing")); + assert_eq!(missing.document, first.document); + assert_eq!( + missing.last_successful_read_at, + first.last_successful_read_at + ); + let startup = snapshot_for(&state, &state.snapshot.lock().unwrap()); + assert_eq!(startup.health, Health::Waiting); + assert!(startup.last_successful_read_at.is_none()); + } + + #[test] + fn cached_reconciliation_only_advances_revisions_for_meaningful_changes() { + let (_directory, state) = sample(); + let initial = state.snapshot.lock().unwrap().clone(); + let first = snapshot_for(&state, &initial); + assert_eq!(cache_snapshot(&state.snapshot, first).unwrap().revision, 1); + let previous = state.snapshot.lock().unwrap().clone(); + let same = snapshot_for(&state, &previous); + assert!(cache_snapshot(&state.snapshot, same).is_none()); + assert_eq!(state.snapshot.lock().unwrap().revision, 1); + assert!(set_warning( + &state.watcher_error, + Some("watch unavailable".into()) + )); + let warning = snapshot_for(&state, &previous); + assert_eq!( + cache_snapshot(&state.snapshot, warning).unwrap().revision, + 2 + ); + set_warning(&state.watcher_error, None); + let previous = state.snapshot.lock().unwrap().clone(); + let recovered = snapshot_for(&state, &previous); + assert_eq!( + cache_snapshot(&state.snapshot, recovered).unwrap().revision, + 3 + ); + let previous = state.snapshot.lock().unwrap().clone(); + assert!(cache_snapshot(&state.snapshot, snapshot_for(&state, &previous)).is_none()); + } + + #[test] + fn parse_failures_keep_successful_read_timestamp_and_warning_changes_are_transitions() { + let (_directory, state) = sample(); + let first = snapshot_for(&state, &state.snapshot.lock().unwrap()); + std::fs::write(&state.path, "invalid").unwrap(); + let failed = snapshot_for(&state, &first); + assert_eq!(failed.health, Health::Stale); + assert_eq!(failed.document, first.document); + assert_eq!( + failed.last_successful_read_at, + first.last_successful_read_at + ); + assert!(set_warning(&state.watcher_error, Some("error".into()))); + assert!(!set_warning(&state.watcher_error, Some("error".into()))); + assert!(set_warning(&state.watcher_error, None)); + assert!(!set_warning(&state.watcher_error, None)); + } +} diff --git a/src-tauri/src/status/store.rs b/src-tauri/src/status/store.rs index ae13a59..a45d185 100644 --- a/src-tauri/src/status/store.rs +++ b/src-tauri/src/status/store.rs @@ -1,12 +1,13 @@ use crate::status::domain::{self, Document, Entry, Point}; use chrono::Utc; use fs2::FileExt; +use serde::{Deserialize, Serialize}; use std::{ fs::{self, File, OpenOptions}, io::{Read, Write}, path::PathBuf, thread, - time::Duration, + time::{Duration, Instant}, }; pub const MAX_STATUS_BYTES: usize = 1024 * 1024; @@ -16,13 +17,16 @@ pub enum StatusError { InvalidDocument(String), Io(String), Conflict(String), + LockTimeout(String), } impl std::fmt::Display for StatusError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::InvalidInput(s) | Self::InvalidDocument(s) | Self::Io(s) | Self::Conflict(s) => { - f.write_str(s) - } + Self::InvalidInput(s) + | Self::InvalidDocument(s) + | Self::Io(s) + | Self::Conflict(s) + | Self::LockTimeout(s) => f.write_str(s), } } } @@ -37,6 +41,81 @@ impl From for StatusError { } } +const MAX_ARCHIVE_BYTES: usize = 5 * 1024 * 1024; +const MAX_PRIVATE_BYTES: usize = 10 * 1024 * 1024; +const ARCHIVE_DAYS: i64 = 30; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemovedPoint { + pub id: String, + pub entry: Entry, + pub point_index: usize, + pub removed_at: String, + pub reason: String, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrivateState { + version: u32, + pins: Vec, + archive: Vec, + pending: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingWrite { + before_raw: Option, + after_raw: String, + after_pins: Vec, +} + +fn trim_archive(archive: &mut Vec) -> Result<(), StatusError> { + let cutoff = Utc::now() - chrono::Duration::days(ARCHIVE_DAYS); + archive.retain(|item| { + chrono::DateTime::parse_from_rfc3339(&item.removed_at).is_ok_and(|at| at >= cutoff) + }); + archive.sort_by(|a, b| a.removed_at.cmp(&b.removed_at).then(a.id.cmp(&b.id))); + while serde_json::to_vec(archive) + .map_err(|error| StatusError::Io(error.to_string()))? + .len() + > MAX_ARCHIVE_BYTES + { + archive.remove(0); + } + Ok(()) +} + +fn overlay_pins(document: &mut Document, pins: &[Entry]) { + for saved in pins { + let matching = document.entries.iter().position(|entry| { + entry.folder_name == saved.folder_name + && entry.session_id == saved.session_id + && entry.branch == saved.branch + }); + if let Some(index) = matching { + let entry = &mut document.entries[index]; + for pin in &saved.points { + entry.points.retain(|point| point.id != pin.id); + entry.points.push(pin.clone()); + } + } else { + let mut restored = saved.clone(); + if restored.session_id.is_some() + && document.entries.iter().any(|entry| { + entry.folder_name == restored.folder_name + && entry.session_id == restored.session_id + }) + { + restored.session_id = None; + } + document.entries.push(restored); + } + } +} + pub struct StatusStore { path: PathBuf, } @@ -59,28 +138,36 @@ impl StatusStore { .ok_or_else(|| StatusError::Io("Status directory unavailable".into())) } fn lock(&self) -> Result { + self.lock_for(Duration::from_secs(2)) + } + fn lock_for(&self, timeout: Duration) -> Result { fs::create_dir_all(self.parent()?)?; let path = self.path.with_extension("lock"); let file = OpenOptions::new() .create(true) + .truncate(false) .read(true) .write(true) .open(&path)?; + let deadline = Instant::now() + timeout; loop { match file.try_lock_exclusive() { Ok(()) => return Ok(SidecarLock { file }), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(2)) + if Instant::now() >= deadline { + return Err(StatusError::LockTimeout("Status file is busy. Retry shortly; another process is holding its lock.".into())); + } + thread::sleep(Duration::from_millis(10)) } Err(error) => return Err(error.into()), } } } - fn read_unlocked(&self) -> Result { + fn read_raw_document(&self) -> Result<(Document, bool), StatusError> { let file = match File::open(&self.path) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(Document::default()) + return Ok((Document::default(), false)) } Err(error) => return Err(error.into()), }; @@ -92,7 +179,23 @@ impl StatusStore { "Status JSON exceeds the 1 MiB limit".into(), )); } - let mut document: Document = serde_json::from_slice(&bytes).map_err(|error| { + let value: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| { + StatusError::InvalidDocument(format!("Invalid status JSON: {error}")) + })?; + let inline_pins = value + .get("entries") + .and_then(|v| v.as_array()) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry + .get("points") + .and_then(|v| v.as_array()) + .is_some_and(|points| { + points.iter().any(|point| point.get("pinned").is_some()) + }) + }) + }); + let mut document: Document = serde_json::from_value(value).map_err(|error| { StatusError::InvalidDocument(format!("Invalid status JSON: {error}")) })?; domain::validate(&document).map_err(StatusError::InvalidDocument)?; @@ -100,9 +203,20 @@ impl StatusStore { domain::assign_legacy_ids(&mut document); domain::validate(&document).map_err(StatusError::InvalidDocument)?; } + Ok((document, inline_pins)) + } + fn read_unlocked(&self) -> Result { + let private = self.recover_private()?; + let (mut document, inline_pins) = self.read_raw_document()?; + overlay_pins(&mut document, &private.pins); + domain::validate(&document).map_err(StatusError::InvalidDocument)?; + if inline_pins { + self.save_unlocked(&document)?; + } Ok(document) } pub fn read(&self) -> Result { + let _lock = self.lock()?; self.read_unlocked() } pub fn read_project(&self, folder: &str, session: &str) -> Result, StatusError> { @@ -125,19 +239,240 @@ impl StatusStore { .filter(|entry| !entry.points.is_empty()) .collect()) } - fn save_unlocked(&self, document: &Document) -> Result { - let bytes = serde_json::to_vec_pretty(document) - .map_err(|error| StatusError::InvalidDocument(error.to_string()))?; - if bytes.len() > MAX_STATUS_BYTES { + fn private_path(&self) -> PathBuf { + self.path.with_extension("private.json") + } + fn read_private(&self) -> Result { + let bytes = match File::open(self.private_path()) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take((MAX_PRIVATE_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + bytes + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PrivateState { + version: 1, + ..Default::default() + }); + } + Err(error) => return Err(error.into()), + }; + if bytes.len() > MAX_PRIVATE_BYTES { return Err(StatusError::InvalidDocument( - "Status JSON exceeds the 1 MiB limit".into(), + "Private status data exceeds its size limit".into(), )); } + let private: PrivateState = serde_json::from_slice(&bytes).map_err(|error| { + StatusError::InvalidDocument(format!("Invalid private status data: {error}")) + })?; + if private.version != 1 { + return Err(StatusError::InvalidDocument( + "Unsupported private status version; original data was preserved".into(), + )); + } + domain::validate(&Document { + version: 2, + entries: private.pins.clone(), + }) + .map_err(StatusError::InvalidDocument)?; + if private + .pins + .iter() + .any(|entry| entry.points.iter().any(|point| !point.pinned)) + { + return Err(StatusError::InvalidDocument( + "Private pin data contains an unpinned point".into(), + )); + } + for item in &private.archive { + if item.entry.points.len() != 1 + || chrono::DateTime::parse_from_rfc3339(&item.removed_at).is_err() + { + return Err(StatusError::InvalidDocument( + "Invalid removed-item record".into(), + )); + } + domain::validate(&Document { + version: 2, + entries: vec![item.entry.clone()], + }) + .map_err(StatusError::InvalidDocument)?; + } + if let Some(pending) = &private.pending { + domain::validate(&Document { + version: 2, + entries: pending.after_pins.clone(), + }) + .map_err(StatusError::InvalidDocument)?; + let after: Document = serde_json::from_str(&pending.after_raw).map_err(|error| { + StatusError::InvalidDocument(format!("Invalid status recovery journal: {error}")) + })?; + domain::validate(&after).map_err(StatusError::InvalidDocument)?; + } + Ok(private) + } + fn persist_bytes(&self, path: &std::path::Path, bytes: &[u8]) -> Result<(), StatusError> { let mut temp = tempfile::NamedTempFile::new_in(self.parent()?)?; - temp.write_all(&bytes)?; + temp.write_all(bytes)?; temp.as_file().sync_all()?; - temp.persist(&self.path) + temp.persist(path) .map_err(|error| StatusError::Io(error.to_string()))?; + Ok(()) + } + fn save_private(&self, private: &PrivateState) -> Result<(), StatusError> { + let bytes = serde_json::to_vec(private) + .map_err(|error| StatusError::InvalidDocument(error.to_string()))?; + if bytes.len() > MAX_PRIVATE_BYTES { + return Err(StatusError::InvalidDocument( + "Private status data exceeds its size limit; no points were deleted".into(), + )); + } + self.persist_bytes(&self.private_path(), &bytes) + } + fn raw_bytes(&self) -> Result>, StatusError> { + match File::open(&self.path) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take((MAX_STATUS_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_STATUS_BYTES { + return Err(StatusError::InvalidDocument( + "Status JSON exceeds the 1 MiB limit".into(), + )); + } + Ok(Some(bytes)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } + } + fn recover_private(&self) -> Result { + let mut private = self.read_private()?; + if let Some(pending) = private.pending.take() { + let raw = self.raw_bytes()?; + if raw.as_deref() == Some(pending.after_raw.as_bytes()) { + private.pins = pending.after_pins; + } else if raw.as_deref() == pending.before_raw.as_deref().map(str::as_bytes) { + self.persist_bytes(&self.path, pending.after_raw.as_bytes())?; + private.pins = pending.after_pins; + } else { + // The raw commit may already have removed a newly pinned point. + // Retain both acknowledged pins and newly pending pins before + // discarding the journal, without overwriting newer legacy bytes. + for mut saved in pending.after_pins { + saved.points.retain(|point| { + !private.pins.iter().any(|entry| { + entry.folder_name == saved.folder_name + && entry.branch == saved.branch + && (entry.session_id == saved.session_id + || entry.session_id.is_none() + || saved.session_id.is_none()) + && entry.points.iter().any(|existing| existing.id == point.id) + }) + }); + if saved.points.is_empty() { + continue; + } + if let Some(entry) = private.pins.iter_mut().find(|entry| { + entry.folder_name == saved.folder_name + && entry.session_id == saved.session_id + && entry.branch == saved.branch + }) { + entry.points.extend(saved.points); + } else { + private.pins.push(saved); + } + } + // Pending pins can belong to a newer branch for the same + // session. Preserve old-branch pins as anonymous user content. + let mut sessions = std::collections::HashSet::new(); + for entry in private.pins.iter_mut().rev() { + if let Some(session) = &entry.session_id { + if !sessions.insert((entry.folder_name.clone(), session.clone())) { + entry.session_id = None; + } + } + } + } + self.save_private(&private)?; + } + Ok(private) + } + fn save_unlocked(&self, document: &Document) -> Result { + let mut raw_document = document.clone(); + let mut pins = Vec::new(); + for entry in &mut raw_document.entries { + let mut pinned = entry.clone(); + pinned.points.retain(|point| point.pinned); + if !pinned.points.is_empty() { + pins.push(pinned); + } + entry.points.retain(|point| !point.pinned); + } + raw_document + .entries + .retain(|entry| !entry.points.is_empty()); + let mut value = serde_json::to_value(&raw_document) + .map_err(|error| StatusError::InvalidDocument(error.to_string()))?; + for entry in value["entries"].as_array_mut().unwrap() { + for point in entry["points"].as_array_mut().unwrap() { + point.as_object_mut().unwrap().remove("pinned"); + } + } + let bytes = serde_json::to_vec_pretty(&value) + .map_err(|error| StatusError::InvalidDocument(error.to_string()))?; + if bytes.len() > MAX_STATUS_BYTES + || serde_json::to_vec(&pins).unwrap().len() > MAX_STATUS_BYTES + { + return Err(StatusError::InvalidDocument( + "Status or pinned data exceeds the 1 MiB limit".into(), + )); + } + let mut private = self.recover_private()?; + let before_raw = self.raw_bytes()?; + // Inline pins are an earlier application format. Preserve exact original + // bytes before the first conversion to the legacy-compatible wire file. + if before_raw.as_ref().is_some_and(|raw| { + serde_json::from_slice::(raw).is_ok_and(|doc| { + doc.get("entries") + .and_then(|v| v.as_array()) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry + .get("points") + .and_then(|v| v.as_array()) + .is_some_and(|points| { + points.iter().any(|point| point.get("pinned").is_some()) + }) + }) + }) + }) + }) { + let backup = self + .path + .with_extension(format!("pre-pins-{}.backup", uuid::Uuid::new_v4())); + self.persist_bytes(&backup, before_raw.as_deref().unwrap())?; + } + if private.pins == pins { + self.persist_bytes(&self.path, &bytes)?; + } else { + private.pending = Some(PendingWrite { + before_raw: before_raw + .map(|raw| { + String::from_utf8(raw) + .map_err(|error| StatusError::InvalidDocument(error.to_string())) + }) + .transpose()?, + after_raw: String::from_utf8(bytes.clone()).unwrap(), + after_pins: pins.clone(), + }); + self.save_private(&private)?; + self.persist_bytes(&self.path, &bytes)?; + private.pins = pins; + private.pending = None; + self.save_private(&private)?; + } Ok(bytes.len()) } pub fn upsert( @@ -274,8 +609,11 @@ impl StatusStore { Ok(true) } /// Merge one dismissed point without replacing newer agent submissions. - pub fn restore(&self, mut saved: Entry, index: usize) -> Result<(), StatusError> { + pub fn restore(&self, saved: Entry, index: usize) -> Result<(), StatusError> { let _lock = self.lock()?; + self.restore_unlocked(saved, index) + } + fn restore_unlocked(&self, mut saved: Entry, index: usize) -> Result<(), StatusError> { let mut document = self.read_unlocked()?; if saved.points.len() != 1 { return Err(StatusError::InvalidInput( @@ -303,23 +641,142 @@ impl StatusStore { self.save_unlocked(&document)?; Ok(()) } + pub fn preview_retention(&self, rules: &[domain::RetentionRule]) -> Result { + let _lock = self.lock()?; + let mut document = self.read_unlocked()?; + let before = document + .entries + .iter() + .map(|entry| entry.points.len()) + .sum::(); + domain::apply_retention(&mut document, rules, Utc::now()) + .map_err(StatusError::InvalidInput)?; + Ok(before + - document + .entries + .iter() + .map(|entry| entry.points.len()) + .sum::()) + } + pub fn list_removed(&self) -> Result, StatusError> { + let _lock = self.lock()?; + let mut private = self.recover_private()?; + trim_archive(&mut private.archive)?; + self.save_private(&private)?; + private.archive.reverse(); + Ok(private.archive) + } + pub fn restore_removed(&self, id: &str) -> Result<(), StatusError> { + let _lock = self.lock()?; + let private = self.recover_private()?; + let item = private + .archive + .iter() + .find(|item| item.id == id) + .ok_or_else(|| { + StatusError::Conflict("This removed item is no longer available".into()) + })?; + let mut saved = item.entry.clone(); + // Restored user-owned content must not immediately expire again. + saved.points[0].pinned = true; + let document = self.read_unlocked()?; + if saved.session_id.is_some() + && document.entries.iter().any(|entry| { + entry.folder_name == saved.folder_name + && entry.session_id == saved.session_id + && entry.branch != saved.branch + }) + { + saved.session_id = None; + } + let already_restored = document.entries.iter().any(|entry| { + entry.folder_name == saved.folder_name + && entry.session_id == saved.session_id + && entry.branch == saved.branch + && entry.points.iter().any(|point| point == &saved.points[0]) + }); + if !already_restored { + self.restore_unlocked(saved, item.point_index)?; + } + let mut private = self.recover_private()?; + private.archive.retain(|item| item.id != id); + self.save_private(&private) + } pub fn apply_retention(&self, rules: &[domain::RetentionRule]) -> Result { let _lock = self.lock()?; let mut document = self.read_unlocked()?; - let changed = domain::apply_retention(&mut document, rules, Utc::now()) + let before = document.clone(); + let now = Utc::now(); + let changed = domain::apply_retention(&mut document, rules, now) .map_err(StatusError::InvalidInput)?; if changed { + let mut private = self.recover_private()?; + for entry in before.entries { + for (index, point) in entry.points.iter().enumerate() { + let kept = document.entries.iter().any(|current| { + current.folder_name == entry.folder_name + && current.session_id == entry.session_id + && current.branch == entry.branch + && current + .points + .iter() + .any(|candidate| candidate.id == point.id) + }); + if kept { + continue; + } + let identity = serde_json::to_vec(&( + &entry.folder_name, + &entry.session_id, + &entry.branch, + &point.id, + &point.submitted_at, + &point.text, + )) + .unwrap(); + let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, &identity).to_string(); + if private.archive.iter().any(|item| item.id == id) { + continue; + } + let time_expired = rules.iter().any(|rule| match rule { + domain::RetentionRule::Age(age) => point + .submitted_at + .as_deref() + .or(Some(entry.updated_at.as_str())) + .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok()) + .is_some_and(|at| { + now.signed_duration_since(at) + .to_std() + .is_ok_and(|elapsed| elapsed > *age) + }), + _ => false, + }); + let mut saved = entry.clone(); + saved.points = vec![point.clone()]; + private.archive.push(RemovedPoint { + id, + entry: saved, + point_index: index, + removed_at: now.to_rfc3339(), + reason: if time_expired { + "Time-based deletion" + } else { + "Count-based deletion" + } + .into(), + }); + } + } + trim_archive(&mut private.archive)?; + // This commit precedes deletion. A failed status write can be retried + // without duplicating archive records, using the stable identity above. + self.save_private(&private)?; self.save_unlocked(&document)?; } Ok(changed) } pub fn prune(&self, age: Duration) -> Result { - let _lock = self.lock()?; - let mut document = self.read_unlocked()?; - let changed = - domain::prune(&mut document, age, Utc::now()).map_err(StatusError::InvalidInput)?; - self.save_unlocked(&document)?; - Ok(changed) + self.apply_retention(&[domain::RetentionRule::Age(age)]) } } @@ -533,6 +990,7 @@ mod tests { waiting.try_lock_exclusive().unwrap(); let newcomer = OpenOptions::new() .create(true) + .truncate(false) .read(true) .write(true) .open(&path) @@ -843,3 +1301,382 @@ mod tests { } } } + +#[cfg(test)] +mod recovery_tests { + use super::*; + fn point(text: &str) -> Point { + Point { + text: text.into(), + done: false, + pinned: false, + link: None, + submitted_at: None, + id: None, + } + } + fn setup() -> (tempfile::TempDir, StatusStore) { + let dir = tempfile::tempdir().unwrap(); + let store = StatusStore::new(dir.path().join("status.json")); + store + .upsert( + "project".into(), + "session".into(), + "main".into(), + vec![point("keep"), point("work")], + ) + .unwrap(); + (dir, store) + } + fn pin_first(store: &StatusStore) -> Entry { + let entry = store.read().unwrap().entries.remove(0); + store + .set_point_pinned( + "project", + Some("session"), + "main", + &entry.updated_at, + entry.points[0].id.as_deref().unwrap(), + true, + ) + .unwrap(); + entry + } + #[test] + fn private_pins_survive_legacy_replacement_and_raw_wire_has_no_pin_fields() { + let (_dir, store) = setup(); + let saved = pin_first(&store); + let raw: serde_json::Value = + serde_json::from_slice(&fs::read(&store.path).unwrap()).unwrap(); + assert_eq!(raw["entries"][0]["points"].as_array().unwrap().len(), 1); + assert!(raw["entries"][0]["points"][0].get("pinned").is_none()); + let mut legacy = raw; + legacy["entries"][0]["points"][0]["text"] = serde_json::json!("legacy update"); + fs::write(&store.path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + let doc = store.read().unwrap(); + assert!(doc.entries[0] + .points + .iter() + .any(|p| p.pinned && p.id == saved.points[0].id)); + assert!(doc.entries[0] + .points + .iter() + .any(|p| p.text == "legacy update")); + store + .upsert( + "project".into(), + "session".into(), + "feature".into(), + vec![point("new branch")], + ) + .unwrap(); + assert!(store + .read() + .unwrap() + .entries + .iter() + .any(|e| e.session_id.is_none() && e.branch == "main" && e.points[0].pinned)); + } + #[test] + fn reading_inline_pin_format_migrates_once_and_preserves_original_backup() { + let (_dir, store) = setup(); + let mut doc = store.read().unwrap(); + doc.entries[0].points[0].pinned = true; + let original = serde_json::to_vec(&doc).unwrap(); + fs::write(&store.path, &original).unwrap(); + assert!(store.read().unwrap().entries[0] + .points + .iter() + .any(|p| p.pinned)); + let raw = fs::read_to_string(&store.path).unwrap(); + assert!(!raw.contains("\"pinned\"")); + let backups = fs::read_dir(store.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|x| x == "backup")) + .collect::>(); + assert_eq!(backups.len(), 1); + assert_eq!(fs::read(backups[0].path()).unwrap(), original); + store.read().unwrap(); + assert_eq!(fs::read_dir(store.parent().unwrap()).unwrap().count(), 4); + } + #[test] + fn interrupted_pin_writes_recover_and_never_overwrite_newer_legacy_data() { + for mode in ["before", "after", "legacy"] { + let (_dir, store) = setup(); + pin_first(&store); + let before = fs::read(&store.path).unwrap(); + let mut after: serde_json::Value = serde_json::from_slice(&before).unwrap(); + after["entries"][0]["points"][0]["text"] = serde_json::json!("pending write"); + let after = serde_json::to_vec(&after).unwrap(); + let mut private = store.read_private().unwrap(); + private.pending = Some(PendingWrite { + before_raw: Some(String::from_utf8(before).unwrap()), + after_raw: String::from_utf8(after.clone()).unwrap(), + after_pins: vec![], + }); + store.save_private(&private).unwrap(); + if mode == "after" { + fs::write(&store.path, &after).unwrap(); + } + if mode == "legacy" { + let mut newer: serde_json::Value = serde_json::from_slice(&after).unwrap(); + newer["entries"][0]["points"][0]["text"] = serde_json::json!("newer legacy"); + fs::write(&store.path, serde_json::to_vec(&newer).unwrap()).unwrap(); + } + let doc = store.read().unwrap(); + if mode == "legacy" { + assert!(doc.entries[0] + .points + .iter() + .any(|p| p.text == "newer legacy")); + assert!(doc.entries[0].points.iter().any(|p| p.pinned)); + } else { + assert_eq!(doc.entries[0].points[0].text, "pending write"); + assert!(!doc.entries[0].points.iter().any(|p| p.pinned)); + } + assert!(store.read_private().unwrap().pending.is_none()); + } + } + #[test] + fn lock_wait_is_bounded_then_succeeds_after_release() { + let (_dir, store) = setup(); + let lock = store.lock().unwrap(); + assert!(matches!( + store.lock_for(Duration::from_millis(20)), + Err(StatusError::LockTimeout(_)) + )); + drop(lock); + assert!(store.lock_for(Duration::from_millis(20)).is_ok()); + } + #[test] + fn archive_retention_is_idempotent_and_restore_is_pinned() { + let (_dir, store) = setup(); + assert_eq!( + store + .preview_retention(&[domain::RetentionRule::CountPerProject(1)]) + .unwrap(), + 1 + ); + store + .apply_retention(&[domain::RetentionRule::CountPerProject(1)]) + .unwrap(); + store + .apply_retention(&[domain::RetentionRule::CountPerProject(1)]) + .unwrap(); + let removed = store.list_removed().unwrap(); + assert_eq!(removed.len(), 1); + store.restore_removed(&removed[0].id).unwrap(); + assert!(store.list_removed().unwrap().is_empty()); + store + .apply_retention(&[ + domain::RetentionRule::Age(Duration::ZERO), + domain::RetentionRule::CountPerProject(0), + ]) + .unwrap(); + assert!(store + .read() + .unwrap() + .entries + .iter() + .flat_map(|e| &e.points) + .any(|p| p.pinned)); + assert!(store.restore_removed(&removed[0].id).is_err()); + } + #[test] + fn corrupt_private_data_prevents_pruning_and_keeps_status_bytes() { + let (_dir, store) = setup(); + let before = fs::read(&store.path).unwrap(); + fs::write(store.private_path(), "broken").unwrap(); + assert!(store + .apply_retention(&[domain::RetentionRule::CountPerProject(0)]) + .is_err()); + assert_eq!(fs::read(&store.path).unwrap(), before); + assert_eq!(fs::read_to_string(store.private_path()).unwrap(), "broken"); + } + #[test] + fn archive_restore_preserves_a_newer_session_branch() { + let (_dir, store) = setup(); + store + .apply_retention(&[domain::RetentionRule::CountPerProject(1)]) + .unwrap(); + let removed = store.list_removed().unwrap(); + store + .upsert( + "project".into(), + "session".into(), + "feature".into(), + vec![point("new work")], + ) + .unwrap(); + store.restore_removed(&removed[0].id).unwrap(); + let doc = store.read().unwrap(); + assert!(doc + .entries + .iter() + .any(|entry| entry.branch == "feature" && entry.points[0].text == "new work")); + assert!(doc.entries.iter().any(|entry| entry.branch == "main" + && entry.session_id.is_none() + && entry.points[0].pinned)); + } + + #[test] + fn preserved_legacy_executable_can_update_while_private_pins_survive() { + let executable = std::path::Path::new("/tmp/sideglance-legacy-remediation-mcp"); + if !executable.is_file() { + return; + } + let home = tempfile::tempdir().unwrap(); + let store = StatusStore::new(home.path().join(".local/share/sideglance/status.json")); + store + .upsert( + "project".into(), + "session".into(), + "main".into(), + vec![point("keep"), point("work")], + ) + .unwrap(); + pin_first(&store); + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + use tokio::io::{AsyncBufReadExt,AsyncWriteExt,BufReader}; + let mut child=tokio::process::Command::new(executable).arg("--mcp").env("HOME",home.path()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).kill_on_drop(true).spawn().unwrap(); + let mut input=child.stdin.take().unwrap();let mut output=BufReader::new(child.stdout.take().unwrap()).lines(); + let result=tokio::time::timeout(Duration::from_secs(4),async { + let initialize=serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"legacy-test","version":"1"}}}); + input.write_all(format!("{initialize}\n").as_bytes()).await.unwrap(); + let init:serde_json::Value=serde_json::from_str(&output.next_line().await.unwrap().unwrap()).unwrap();assert!(init.get("error").is_none()); + let ready=serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized"}); + let write=serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_status","arguments":{"folder_name":"project","session_id":"session","branch":"main","points":[{"text":"legacy work"}]}}}); + input.write_all(format!("{ready}\n{write}\n").as_bytes()).await.unwrap(); + let reply:serde_json::Value=serde_json::from_str(&output.next_line().await.unwrap().unwrap()).unwrap(); + assert!(reply.get("error").is_none(),"{reply}");assert_ne!(reply.pointer("/result/isError"),Some(&serde_json::json!(true))); + }).await; + drop(input);drop(output);let _=child.kill().await;let _=child.wait().await;result.unwrap(); + }); + let doc = store.read().unwrap(); + assert!(doc.entries[0] + .points + .iter() + .any(|p| p.pinned && p.text == "keep")); + assert!(doc.entries[0] + .points + .iter() + .any(|p| p.text == "legacy work")); + } + #[test] + fn interrupted_new_pin_survives_an_intervening_legacy_write() { + let (_dir, store) = setup(); + let original = fs::read(&store.path).unwrap(); + let mut doc = store.read().unwrap(); + let mut saved = doc.entries[0].clone(); + saved.points = vec![saved.points[0].clone()]; + saved.points[0].pinned = true; + doc.entries[0].points.remove(0); + let mut after = serde_json::to_value(doc).unwrap(); + for point in after["entries"][0]["points"].as_array_mut().unwrap() { + point.as_object_mut().unwrap().remove("pinned"); + } + let after = serde_json::to_string(&after).unwrap(); + let mut private = store.read_private().unwrap(); + private.pending = Some(PendingWrite { + before_raw: Some(String::from_utf8(original).unwrap()), + after_raw: after.clone(), + after_pins: vec![saved], + }); + store.save_private(&private).unwrap(); + let mut legacy: serde_json::Value = serde_json::from_str(&after).unwrap(); + legacy["entries"][0]["points"][0]["text"] = serde_json::json!("newer legacy report"); + let newer = serde_json::to_vec(&legacy).unwrap(); + fs::write(&store.path, &newer).unwrap(); + let doc = store.read().unwrap(); + assert!(doc.entries[0] + .points + .iter() + .any(|point| point.pinned && point.text == "keep")); + assert!(doc.entries[0] + .points + .iter() + .any(|point| point.text == "newer legacy report")); + assert_eq!(fs::read(&store.path).unwrap(), newer); + assert!(store.read_private().unwrap().pending.is_none()); + } + #[test] + fn legacy_age_deletion_records_the_correct_reason() { + let (_dir, store) = setup(); + let mut doc = store.read().unwrap(); + doc.entries[0].updated_at = (Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); + for point in &mut doc.entries[0].points { + point.submitted_at = None; + } + fs::write(&store.path, serde_json::to_vec(&doc).unwrap()).unwrap(); + store.prune(Duration::from_secs(60)).unwrap(); + assert!(store + .list_removed() + .unwrap() + .iter() + .all(|item| item.reason == "Time-based deletion")); + } + #[test] + fn pending_pin_merge_normalizes_session_identity_across_branches() { + let (_dir, store) = setup(); + pin_first(&store); + let mut raw: serde_json::Value = + serde_json::from_slice(&fs::read(&store.path).unwrap()).unwrap(); + raw["entries"][0]["branch"] = serde_json::json!("feature"); + fs::write(&store.path, serde_json::to_vec(&raw).unwrap()).unwrap(); + let before = fs::read_to_string(&store.path).unwrap(); + let doc = store.read().unwrap(); + let mut after_pins = doc.entries.clone(); + for entry in &mut after_pins { + for point in &mut entry.points { + point.pinned = true; + } + } + let mut private = store.read_private().unwrap(); + private.pending = Some(PendingWrite { + before_raw: Some(before), + after_raw: r#"{"version":2,"entries":[]}"#.into(), + after_pins, + }); + store.save_private(&private).unwrap(); + raw["entries"][0]["points"][0]["id"] = serde_json::json!("later-point"); + raw["entries"][0]["points"][0]["text"] = serde_json::json!("later report"); + fs::write(&store.path, serde_json::to_vec(&raw).unwrap()).unwrap(); + let doc = store.read().unwrap(); + assert_eq!(doc.entries.iter().flat_map(|e| &e.points).count(), 3); + assert!(doc + .entries + .iter() + .any(|e| e.branch == "main" && e.session_id.is_none() && e.points[0].pinned)); + assert!(doc + .entries + .iter() + .any(|e| e.branch == "feature" && e.points.iter().any(|p| p.pinned))); + store.read().unwrap(); // the normalized private state must stay readable. + } + #[test] + fn automatic_history_trims_oldest_records_by_age_and_byte_cap() { + let (_dir, store) = setup(); + let entry = store.read().unwrap().entries.remove(0); + let mut archive = Vec::new(); + for index in 0..8 { + let mut saved = entry.clone(); + saved.points.truncate(1); + saved.points[0].text = "x".repeat(850_000); + archive.push(RemovedPoint { + id: format!("item-{index}"), + entry: saved, + point_index: 0, + removed_at: (Utc::now() + - chrono::Duration::days(if index == 0 { 40 } else { 8 - index })) + .to_rfc3339(), + reason: "Time-based deletion".into(), + }); + } + trim_archive(&mut archive).unwrap(); + assert!(!archive.iter().any(|item| item.id == "item-0")); + assert!(!archive.iter().any(|item| item.id == "item-1")); + assert!(archive.iter().any(|item| item.id == "item-7")); + assert!(serde_json::to_vec(&archive).unwrap().len() <= MAX_ARCHIVE_BYTES); + } +} diff --git a/src-tauri/src/status/watcher.rs b/src-tauri/src/status/watcher.rs index 0e2df6b..717f91e 100644 --- a/src-tauri/src/status/watcher.rs +++ b/src-tauri/src/status/watcher.rs @@ -8,109 +8,122 @@ use crate::{ }; use notify::{RecursiveMode, Watcher}; use std::{ - path::PathBuf, sync::{mpsc, Arc}, thread, time::{Duration, Instant}, }; use tauri::AppHandle; +const QUIET_PERIOD: Duration = Duration::from_millis(120); +const MAX_BURST_WAIT: Duration = Duration::from_millis(500); +const RECONCILE_PERIOD: Duration = Duration::from_secs(30); +const INITIAL_RETRY: Duration = Duration::from_millis(250); + +// The maximum burst age prevents a busy writer from postponing reads forever. +struct RefreshSchedule { + reconcile_at: Instant, + dirty_since: Option, + quiet_at: Option, +} +impl RefreshSchedule { + fn new(now: Instant) -> Self { + Self { + reconcile_at: now + RECONCILE_PERIOD, + dirty_since: None, + quiet_at: None, + } + } + fn changed(&mut self, now: Instant) { + self.dirty_since.get_or_insert(now); + self.quiet_at = Some(now + QUIET_PERIOD); + } + fn deadline(&self) -> Instant { + let dirty = self + .dirty_since + .map(|since| self.quiet_at.unwrap().min(since + MAX_BURST_WAIT)); + dirty.map_or(self.reconcile_at, |at| at.min(self.reconcile_at)) + } + fn due(&self, now: Instant) -> bool { + now >= self.deadline() + } + fn refreshed(&mut self, now: Instant) { + self.dirty_since = None; + self.quiet_at = None; + self.reconcile_at = now + RECONCILE_PERIOD; + } +} + pub fn start(app: AppHandle, state: Arc, owner: Arc) { thread::spawn(move || { let (tx, rx) = mpsc::channel(); - let mut watcher = match notify::recommended_watcher(tx.clone()) { - Ok(watcher) => Some(watcher), - Err(error) => { - publish_watcher_error( - &app, - &state, - format!("Unable to start status file watcher: {error}"), - ); - None - } - }; - let mut watched_parent: Option = None; - let mut last_reconcile = Instant::now(); + let mut watcher = None; + let parent = state + .path + .parent() + .unwrap_or(std::path::Path::new(".")) + .to_path_buf(); + let mut schedule = RefreshSchedule::new(Instant::now()); let mut next_watch_attempt = Instant::now(); - let mut retry_delay = Duration::from_millis(250); - let mut last_watcher_error: Option = None; + let mut retry_delay = INITIAL_RETRY; loop { - if watcher.is_none() && Instant::now() >= next_watch_attempt { - if let Ok(new_watcher) = notify::recommended_watcher(tx.clone()) { - watcher = Some(new_watcher); - } else { - next_watch_attempt = Instant::now() + retry_delay; - retry_delay = (retry_delay * 2).min(Duration::from_secs(30)); - } - } - let parent = state - .path - .parent() - .unwrap_or(std::path::Path::new(".")) - .to_path_buf(); - if watched_parent.as_ref() != Some(&parent) { - if let Some(old) = watched_parent.take() { - if let Some(w) = watcher.as_mut() { - let _ = w.unwatch(&old); + let now = Instant::now(); + if watcher.is_none() && now >= next_watch_attempt { + let attempt = + notify::recommended_watcher(tx.clone()).and_then(|mut new_watcher| { + new_watcher.watch(&parent, RecursiveMode::NonRecursive)?; + Ok(new_watcher) + }); + match attempt { + Ok(new_watcher) => { + watcher = Some(new_watcher); + retry_delay = INITIAL_RETRY; + watcher_recovered(&app, &state); + // Reconcile changes made while registration was unavailable. + schedule.changed(now); } - } - if let Some(w) = watcher.as_mut() { - match w.watch(&parent, RecursiveMode::NonRecursive) { - Ok(()) => { - watched_parent = Some(parent); - last_watcher_error = None; - watcher_recovered(&app, &state); - } - Err(error) => { - let message = format!("Unable to watch the status directory: {error}"); - if last_watcher_error.as_deref() != Some(&message) { - publish_watcher_error(&app, &state, message.clone()); - last_watcher_error = Some(message); - } - watcher = None; - next_watch_attempt = Instant::now() + retry_delay; - retry_delay = (retry_delay * 2).min(Duration::from_secs(30)); - } + Err(error) => { + publish_watcher_error( + &app, + &state, + format!("Unable to watch the status directory: {error}"), + ); + next_watch_attempt = now + retry_delay; + retry_delay = (retry_delay * 2).min(RECONCILE_PERIOD); } } } - match rx.recv_timeout(Duration::from_millis(250)) { + let deadline = if watcher.is_none() { + schedule.deadline().min(next_watch_attempt) + } else { + schedule.deadline() + }; + match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { Ok(Ok(event)) => { - if !event.paths.iter().any(|path| path == &state.path) { - continue; + if !matches!(event.kind, notify::EventKind::Access(_)) + && (event.need_rescan() + || event + .paths + .iter() + .any(|path| path == &state.path || path == &parent)) + { + schedule.changed(Instant::now()); } - thread::sleep(Duration::from_millis(120)); - if let Err(error) = clear_expired_points(&state, &owner) { - publish_retention_error( - &app, - &state, - format!("Unable to prune status points: {error}"), - ); - } else { - retention_recovered(&app, &state); - } - update(&app, &state); } Ok(Err(error)) => { - let message = format!("Status watcher error: {error}"); - if last_watcher_error.as_deref() != Some(&message) { - publish_watcher_error(&app, &state, message.clone()); - last_watcher_error = Some(message); - } - watched_parent = None; + publish_watcher_error(&app, &state, format!("Status watcher error: {error}")); watcher = None; next_watch_attempt = Instant::now() + retry_delay; - retry_delay = (retry_delay * 2).min(Duration::from_secs(30)); + retry_delay = (retry_delay * 2).min(RECONCILE_PERIOD); } Err(mpsc::RecvTimeoutError::Disconnected) => { + // tx is retained by this thread; disconnect is terminal rather than a hot retry loop. publish_watcher_error(&app, &state, "Status watcher stopped"); - watched_parent = None; - watcher = None; - next_watch_attempt = Instant::now() + retry_delay; + return; } Err(mpsc::RecvTimeoutError::Timeout) => {} } - if last_reconcile.elapsed() >= Duration::from_secs(30) { + // This runs after every event, including unrelated directory activity. + if schedule.due(Instant::now()) { if let Err(error) = clear_expired_points(&state, &owner) { publish_retention_error( &app, @@ -121,8 +134,50 @@ pub fn start(app: AppHandle, state: Arc, owner: Arc) retention_recovered(&app, &state); } update(&app, &state); - last_reconcile = Instant::now(); + schedule.refreshed(Instant::now()); } } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn burst_waits_for_quiet_but_has_a_maximum_age() { + let now = Instant::now(); + let mut schedule = RefreshSchedule::new(now); + schedule.changed(now); + schedule.changed(now + Duration::from_millis(100)); + assert!(!schedule.due(now + Duration::from_millis(219))); + assert!(schedule.due(now + Duration::from_millis(220))); + for ms in [200, 300, 400, 490] { + schedule.changed(now + Duration::from_millis(ms)); + } + assert!(!schedule.due(now + Duration::from_millis(499))); + assert!(schedule.due(now + MAX_BURST_WAIT)); + } + + #[test] + fn unrelated_activity_cannot_postpone_reconciliation_and_idle_wait_is_long() { + let now = Instant::now(); + let mut schedule = RefreshSchedule::new(now); + assert_eq!(schedule.deadline(), now + RECONCILE_PERIOD); + // Unrelated events do not call changed. Reconciliation is checked after each event. + for second in 1..30 { + assert!(!schedule.due(now + Duration::from_secs(second))); + } + assert!(schedule.due(now + RECONCILE_PERIOD)); + schedule.refreshed(now + RECONCILE_PERIOD); + assert_eq!(schedule.deadline(), now + RECONCILE_PERIOD * 2); + } + + #[test] + fn a_nearby_periodic_deadline_wins_over_continuous_changes() { + let now = Instant::now(); + let mut schedule = RefreshSchedule::new(now); + schedule.changed(now + RECONCILE_PERIOD - Duration::from_millis(10)); + assert_eq!(schedule.deadline(), now + RECONCILE_PERIOD); + } +} diff --git a/src/App.svelte b/src/App.svelte index cae7d1c..e1ce76c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,6 @@ @@ -42,7 +80,8 @@

Agent integration

Install the bundled skill into the locations used by each agent, then configure MCP separately - when needed. + when needed. After installing or updating, restart existing agent sessions. Test connection + opens a new read-only connection; it does not reload those sessions.

{#if skill}
@@ -65,7 +104,10 @@
{#each skill.agents as agent} void testConnection(id)} + onRepair={(id) => void toggleMcp(id, false)} + testResult={testResults[agent.id] ?? ''} onToggle={(id, registered) => void toggleMcp(id, registered)} />{/each}
@@ -74,7 +116,10 @@
{#each skill.additionalAgents as agent} void testConnection(id)} + onRepair={(id) => void toggleMcp(id, false)} + testResult={testResults[agent.id] ?? ''} onToggle={(id, registered) => void toggleMcp(id, registered)} />{/each}
diff --git a/src/integrations/McpAgentRow.svelte b/src/integrations/McpAgentRow.svelte index 4391fd1..1a3685e 100644 --- a/src/integrations/McpAgentRow.svelte +++ b/src/integrations/McpAgentRow.svelte @@ -2,20 +2,53 @@ import type { AgentInstallation } from '../shared/types'; export let agent: AgentInstallation; export let busy = false; + export let onTest: (id: string) => void; + export let onRepair: (id: string) => void; + export let testResult = ''; export let onToggle: (id: string, registered: boolean) => void;
{agent.name}{agent.configPath}
{agent.mcpRegistered ? 'MCP registered' : 'MCP not registered'}{agent.configurationValid + ? 'MCP configuration valid' + : agent.mcpRegistered + ? 'MCP configuration needs repair' + : 'MCP not configured'}
+{#if agent.mcpRegistered} +
+ + +
+{/if} +{#if testResult}

{testResult}

{/if} {#if agent.error}

{agent.error}

{/if} diff --git a/src/settings/LazySettings.svelte b/src/settings/LazySettings.svelte new file mode 100644 index 0000000..6202822 --- /dev/null +++ b/src/settings/LazySettings.svelte @@ -0,0 +1,26 @@ + + +{#await settings} +

Loading settings…

+{:then loaded} + +{:catch} +
+

Settings could not be loaded.

+ +
+{/await} + + diff --git a/src/settings/RetentionPreview.svelte b/src/settings/RetentionPreview.svelte new file mode 100644 index 0000000..c77e201 --- /dev/null +++ b/src/settings/RetentionPreview.svelte @@ -0,0 +1,82 @@ + + +
+ {#if count === null} + + {:else} +

+ About {count} + {count === 1 ? 'item would' : 'items would'} be removed now. Incoming updates may change this count. + Removed items can be recovered from Recently removed. +

+ + {/if} + {#if error}

{error}

{/if} +
+ + diff --git a/src/settings/RetentionSettings.svelte b/src/settings/RetentionSettings.svelte index e32d4bb..d904749 100644 --- a/src/settings/RetentionSettings.svelte +++ b/src/settings/RetentionSettings.svelte @@ -1,8 +1,11 @@ + +
{ + opened = event.currentTarget.open; + if (opened) void load(); + }} +> + Recently removed + {#if opened} +

Automatically removed items are kept for up to 30 days within the local history limit.

+ {#if busy}

Loading…

{/if} + {#if error}

{error}

+ {/if} + {#if !busy && !error && !items.length}

No recently removed items.

{/if} +
    + {#each items.slice(0, shown) as item (item.id)} +
  • + {item.entry.points[0]?.text ?? 'Removed item'} + {item.entry.folder_name} · {item.reason} · + + +
  • + {/each} +
+ {#if items.length > shown}{/if} + {/if} +
+ + diff --git a/src/viewer/StatusPoint.svelte b/src/viewer/StatusPoint.svelte new file mode 100644 index 0000000..45e498e --- /dev/null +++ b/src/viewer/StatusPoint.svelte @@ -0,0 +1,189 @@ + + +
    +
  • + {#if sessionLabel}{sessionLabel}: {entry.session_id}{/if} + {#if point.done}DONE: {' '}{/if}{point.text}{#if link}{' '}{linkLabel(link)}{/if} + + + {relativeTime(point.submitted_at ?? entry.updated_at, now)} + + +
  • +
+ + diff --git a/src/viewer/ToolbarUpdateButton.svelte b/src/viewer/ToolbarUpdateButton.svelte new file mode 100644 index 0000000..b1f01aa --- /dev/null +++ b/src/viewer/ToolbarUpdateButton.svelte @@ -0,0 +1,268 @@ + + + +{message || + (stage === 'downloading' ? `Downloading update: ${progress}` : busy ? label : '')} + + diff --git a/src/viewer/Viewer.svelte b/src/viewer/Viewer.svelte index 415a059..cf1a58d 100644 --- a/src/viewer/Viewer.svelte +++ b/src/viewer/Viewer.svelte @@ -1,6 +1,4 @@
- - + - {#if import.meta.env.DEV} - dev - {/if} -
- {#if !import.meta.env.DEV && availableVersion} - - {/if} - - - - -
+ {#if undoNotice}
(hoveredHeadings = new Set())} - onselectstart={() => controller.setSelection(true)} + onselectstart={(event) => { + const target = + event.target instanceof Element ? event.target : (event.target as Node).parentElement; + if (!target?.closest('button, a, input, select, textarea, summary')) + controller.setSelection(true); + }} > + {#if health !== 'current'} +
+ {health === 'waiting' + ? 'Waiting for updates' + : health === 'stale' + ? 'Updates are stale' + : 'Updates need attention'} + {#if entries.length}

Showing the last available status.

{/if} + {#if lastSuccessfulReadAt}

+ Last read {relativeTime(lastSuccessfulReadAt, now)} ago. +

{/if} +
+ {/if} + {#if recoveryWarning}{/if} + {#if selectedText} +
+ Updates are paused while text is selected. + +
+ {/if} + {#if !rows.length} +
+

+ {health === 'current' ? 'No status items yet.' : 'Your agent updates will appear here.'} +

+ +
+ {/if} {#each rows as row (row.key)}
{:else} {@const { entry, point, pointIndex } = row} - {@const link = safeLink(point.link)} -
    -
  • - {#if point.done}DONE: {' '}{/if}{point.text}{#if link}{' '}{linkLabel(link)}{/if} - - - {relativeTime(point.submitted_at ?? entry.updated_at, now)} - - -
  • -
+ togglePointPin(entry, pointIndex)} + onDismiss={() => dismissPoint(entry, pointIndex)} + /> {/if}
{/each} + {#if statusError}{/if}
@@ -639,15 +525,6 @@ .motion-row p { color: var(--muted); } - .motion-row ul { - margin-bottom: 0; - } - .motion-row ul:not(.branch-end) { - padding-bottom: 0.5em; - } - .motion-row ul.branch-end { - padding-bottom: 1.1em; - } button { flex: none; border: 1px solid transparent; @@ -664,28 +541,6 @@ cursor: default; opacity: 0.65; } - .line-art, - .bottom-line-art { - position: absolute; - z-index: 0; - height: auto; - color: var(--accent); - pointer-events: none; - } - .line-art { - top: 0; - right: 0; - width: min(880px, 80%); - aspect-ratio: 1000 / 480; - opacity: 0.48; - } - .bottom-line-art { - right: 0; - bottom: 0; - width: 100%; - aspect-ratio: 1000 / 420; - opacity: 0.55; - } .undo-notice { position: fixed; z-index: 3; @@ -731,152 +586,38 @@ animation: none; } } - .development-label { - position: absolute; - z-index: 2; - top: 18px; - right: 16px; - transform: translateY(-50%); - padding: 0.14rem 0.5rem; - color: var(--muted); - font-size: 0.65rem; - letter-spacing: 0.04em; - pointer-events: none; - user-select: none; - } - .development-label + .document-actions { - right: 60px; - } - .document-actions { + .selection-notice { position: fixed; - z-index: 2; - top: 8px; - right: 10px; - display: flex; - gap: 0.35rem; - opacity: 1; - transition: opacity 160ms var(--ease-gentle-out); + bottom: 12px; + left: 12px; + right: 12px; + z-index: 4; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; } - .document-actions button { - padding: 0.28rem 0.5rem; - font-size: 0.7rem; + .health-notice, + .empty-state { + margin-bottom: 0.8rem; + padding: 0.65rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + font-size: 0.85rem; + overflow-wrap: anywhere; } - .document-actions .update-button { - border-radius: 999px; - color: var(--accent); - background: color-mix(in srgb, var(--bg) 88%, transparent); - backdrop-filter: blur(28px) saturate(1.15); - -webkit-backdrop-filter: blur(28px) saturate(1.15); + .health-notice p { + margin-top: 0.3rem; } - .document-actions button:not(:disabled):hover { - border-color: var(--border); + .health-notice button, + .empty-state button { color: var(--accent); } - .document-actions button:focus-visible { + button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } - .icon-button { - min-width: 1.8rem; - padding-inline: 0.35rem !important; - } - .document-actions.window-unfocused { - pointer-events: none; - opacity: 0; - } .status-error { color: #c43d35; font-size: 0.8rem; } - .status-point { - position: relative; - line-height: 1.25; - transition: opacity 160ms var(--ease-gentle-out); - } - .status-point.removing { - pointer-events: none; - } - .status-point.expiring-soon { - opacity: 0.42; - } - .development-label, - .point-actions { - border-radius: 999px; - color: var(--muted); - background: color-mix(in srgb, var(--bg) 88%, transparent); - backdrop-filter: blur(28px) saturate(1.15); - -webkit-backdrop-filter: blur(28px) saturate(1.15); - box-shadow: - 0 2px 8px color-mix(in srgb, var(--text) 8%, transparent), - inset 0 0 0 1px color-mix(in srgb, var(--bg) 28%, transparent); - } - .point-actions { - position: absolute; - top: 50%; - right: 0; - display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0.14rem 0.28rem 0.14rem 0.5rem; - transform: translateY(-50%); - opacity: 0; - pointer-events: none; - font-size: 0.78em; - white-space: nowrap; - transition: opacity 120ms var(--ease-gentle-out); - } - .status-point:hover .point-actions, - .status-point:focus-within .point-actions { - opacity: 1; - pointer-events: auto; - } - .point-actions.pinned { - opacity: 1; - pointer-events: auto; - } - .pin-point { - display: inline-flex; - padding: 0.15rem; - color: inherit; - } - .pin-point.active, - .pin-point:hover { - color: var(--accent); - } - .pin-point:focus-visible { - outline: 2px solid var(--accent); - } - .dismiss-point { - padding: 0 0.28rem; - border: 0; - border-radius: 0.3rem; - color: inherit; - background: transparent; - font-size: 1.25em; - line-height: 1; - } - .dismiss-point:not(:disabled):hover { - color: #c43d35; - background: color-mix(in srgb, #c43d35 12%, transparent); - } - .dismiss-point:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - @media (max-width: 440px), (max-height: 360px) { - .line-art-extra { - display: none; - } - } - @media (max-width: 340px), (max-height: 280px) { - .line-art-medium { - display: none; - } - } - @media (prefers-reduced-motion: reduce) { - .line-art animate, - .bottom-line-art animate { - display: none; - } - } diff --git a/src/viewer/ViewerArtwork.svelte b/src/viewer/ViewerArtwork.svelte new file mode 100644 index 0000000..bbcd1a9 --- /dev/null +++ b/src/viewer/ViewerArtwork.svelte @@ -0,0 +1,176 @@ + + + + + + diff --git a/src/viewer/ViewerToolbar.svelte b/src/viewer/ViewerToolbar.svelte new file mode 100644 index 0000000..cb9c836 --- /dev/null +++ b/src/viewer/ViewerToolbar.svelte @@ -0,0 +1,131 @@ + + +{#if import.meta.env.DEV} + dev +{/if} +
+
+ (updatePersistent = value)} + /> +
+ + + + +
+ + diff --git a/src/viewer/collapse-state.ts b/src/viewer/collapse-state.ts new file mode 100644 index 0000000..7a084ad --- /dev/null +++ b/src/viewer/collapse-state.ts @@ -0,0 +1,23 @@ +const storageKey = 'sideglance.collapsed.v1'; + +export function loadCollapsed(): Set { + try { + const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); + if (!Array.isArray(value)) return new Set(); + return new Set( + value + .slice(0, 500) + .filter((key): key is string => typeof key === 'string' && key.length <= 2048), + ); + } catch { + return new Set(); + } +} + +export function saveCollapsed(value: ReadonlySet) { + try { + localStorage.setItem(storageKey, JSON.stringify([...value].slice(0, 500))); + } catch { + /* A disabled or full storage area must not prevent using the viewer. */ + } +} diff --git a/src/viewer/controller.ts b/src/viewer/controller.ts index 7f29112..90e960c 100644 --- a/src/viewer/controller.ts +++ b/src/viewer/controller.ts @@ -1,7 +1,22 @@ import type { Settings, Snapshot, StatusEntry, StatusPoint } from '../shared/types'; +// Browser timeouts use a signed 32-bit millisecond delay. +export const MAX_TIMEOUT_MS = 2_147_483_647; +export const EXPIRY_WARNING_MS = 10 * 60_000; + +export function expiryWarningAt(submittedAt: string, minutes: number): number { + return Date.parse(submittedAt) + minutes * 60_000 - EXPIRY_WARNING_MS; +} + +export function expirySoon(submittedAt: string, minutes: number | null, now: number): boolean { + return minutes != null && expiryWarningAt(submittedAt, minutes) <= now; +} + export type ViewerState = { revision: number; + health: Snapshot['health']; + lastSuccessfulReadAt: string | null; + recoveryWarning: string | null; entries: StatusEntry[]; settings: Settings | null; pendingSnapshot: Snapshot | null; @@ -40,6 +55,9 @@ type DismissArgs = { }; const initialState = (): ViewerState => ({ revision: -1, + health: 'waiting', + lastSuccessfulReadAt: null, + recoveryWarning: null, entries: [], settings: null, pendingSnapshot: null, @@ -185,7 +203,11 @@ export class ViewerController { (this.state.pendingSnapshot && value.revision < this.state.pendingSnapshot.revision) ) return; + this.state.health = value.health; + this.state.lastSuccessfulReadAt = value.last_successful_read_at ?? null; + this.state.recoveryWarning = value.recovery_warning ?? null; this.state.pendingSnapshot = value; + this.notify(); this.flushPending(); } setSelection(selected: boolean) { @@ -324,35 +346,31 @@ export class ViewerController { ); } expirySoon(value: string) { - const minutes = this.state.settings?.autoClearAfterMinutes; - return ( - minutes != null && - new Date(value).getTime() + minutes * 60_000 - this.state.now <= 10 * 60_000 - ); + return expirySoon(value, this.state.settings?.autoClearAfterMinutes ?? null, this.state.now); } private scheduleExpiry() { if (this.expiryTimer !== undefined) this.timers.clearTimeout(this.expiryTimer); this.expiryTimer = undefined; + this.state.now = this.timers.now(); const minutes = this.state.settings?.autoClearAfterMinutes; if (minutes == null) return; - const next = this.state.entries - .flatMap((entry) => - entry.points - .filter((point) => !point.pinned) - .map( - (point) => - new Date(point.submitted_at ?? entry.updated_at).getTime() + - minutes * 60_000 - - 10 * 60_000, - ), - ) - .filter((time) => time > this.state.now) - .sort((a, b) => a - b)[0]; - if (next === undefined) return; - this.expiryTimer = this.timers.setTimeout(() => { - this.expiryTimer = undefined; - this.refreshNow(); - }, next - this.state.now); + let next = Infinity; + for (const entry of this.state.entries) { + for (const point of entry.points) { + if (point.pinned) continue; + const time = expiryWarningAt(point.submitted_at ?? entry.updated_at, minutes); + if (time > this.state.now) next = Math.min(next, time); + } + } + if (!Number.isFinite(next)) return; + this.expiryTimer = this.timers.setTimeout( + () => { + this.expiryTimer = undefined; + // A capped timeout or a wall-clock change requires another calculation. + this.refreshNow(); + }, + Math.min(MAX_TIMEOUT_MS, next - this.state.now), + ); } dismiss( entry: StatusEntry, diff --git a/status.schema.json b/status.schema.json index 50234f3..23ad20d 100644 --- a/status.schema.json +++ b/status.schema.json @@ -121,7 +121,7 @@ "submitted_at": { "type": "string", "format": "date-time", - "description": "RFC 3339 timestamp. Existing point IDs retain their timestamp when omitted on edit; a valid caller-supplied timestamp is preserved." + "description": "Storage-only RFC 3339 timestamp. Existing point IDs retain their submitted time. MCP tool payloads do not accept this field." }, "id": { "type": "string", @@ -130,7 +130,8 @@ }, "pinned": { "type": "boolean", - "default": false + "default": false, + "description": "Accepted only for migration from the inline-pin format. New shared-file writes omit this field; user pins are stored privately." } } } diff --git a/tests/artwork-fixture.html b/tests/artwork-fixture.html new file mode 100644 index 0000000..a6bb379 --- /dev/null +++ b/tests/artwork-fixture.html @@ -0,0 +1,10 @@ + + + + + Artwork lifecycle + + + + + diff --git a/tests/artwork-fixture.js b/tests/artwork-fixture.js new file mode 100644 index 0000000..89a84bb --- /dev/null +++ b/tests/artwork-fixture.js @@ -0,0 +1,3 @@ +import { mount } from 'svelte'; +import ViewerArtwork from '../src/viewer/ViewerArtwork.svelte'; +mount(ViewerArtwork, { target: document.body }); diff --git a/tests/artwork.spec.ts b/tests/artwork.spec.ts new file mode 100644 index 0000000..09457f6 --- /dev/null +++ b/tests/artwork.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from 'playwright/test'; + +test('artwork pauses actual SVG playback on blur and reduced motion', async ({ page }) => { + await page.goto('/tests/artwork-fixture.html'); + await expect(page.locator('svg')).toHaveCount(2); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect + .poll(() => + page + .locator('svg') + .first() + .evaluate((svg: SVGSVGElement) => svg.animationsPaused()), + ) + .toBe(false); + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); + const paused = await page + .locator('svg') + .first() + .evaluate(async (svg: SVGSVGElement) => { + const before = svg.getCurrentTime(); + await new Promise((resolve) => setTimeout(resolve, 80)); + return { paused: svg.animationsPaused(), elapsed: svg.getCurrentTime() - before }; + }); + expect(paused.paused).toBe(true); + expect(paused.elapsed).toBeLessThan(0.01); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect + .poll(() => + page + .locator('svg') + .first() + .evaluate((svg: SVGSVGElement) => svg.animationsPaused()), + ) + .toBe(true); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await expect + .poll(() => + page + .locator('svg') + .first() + .evaluate((svg: SVGSVGElement) => svg.animationsPaused()), + ) + .toBe(false); +}); diff --git a/tests/expiry-timer.spec.ts b/tests/expiry-timer.spec.ts new file mode 100644 index 0000000..e740583 --- /dev/null +++ b/tests/expiry-timer.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from 'playwright/test'; + +test('30-day retention uses a real browser timeout without overflowing into a callback loop', async ({ + page, +}) => { + await page.route('**/expiry-harness', (route) => + route.fulfill({ contentType: 'text/html', body: 'Expiry timer' }), + ); + await page.goto('/expiry-harness'); + const result = await page.evaluate(async () => { + const { ViewerController, MAX_TIMEOUT_MS } = await import('/src/viewer/controller.ts'); + const delays: number[] = []; + let fired = 0; + const controller = new ViewerController( + { + now: () => Date.now(), + reducedMotion: () => true, + setTimeout: (handler: () => void, delay: number) => { + delays.push(delay); + return window.setTimeout(() => { + fired++; + handler(); + }, delay); + }, + clearTimeout: (id: number) => window.clearTimeout(id), + }, + { + exit() {}, + move(commit: () => void) { + commit(); + }, + enter() {}, + async dismiss() {}, + restore() {}, + dispose() {}, + }, + ); + controller.acceptSettings({ autoClearAfterMinutes: 43_200, undoLimit: 3 }); + const at = new Date().toISOString(); + controller.acceptSnapshot({ + revision: 1, + document: { + version: 2, + entries: [ + { + folder_name: 'timer-test', + session_id: 'test', + branch: 'main', + updated_at: at, + points: [ + { + id: 'one', + text: 'fresh', + done: false, + pinned: false, + link: null, + submitted_at: at, + }, + ], + }, + ], + }, + health: 'current', + error: null, + path: null, + modified_at: null, + }); + await new Promise((resolve) => window.setTimeout(resolve, 40)); + controller.dispose(); + return { delays, fired, maximum: MAX_TIMEOUT_MS }; + }); + expect(result.delays).toEqual([result.maximum]); + expect(result.fired).toBe(0); +}); diff --git a/tests/lazy-route-fixture.js b/tests/lazy-route-fixture.js new file mode 100644 index 0000000..5226a62 --- /dev/null +++ b/tests/lazy-route-fixture.js @@ -0,0 +1,43 @@ +import { mount } from 'svelte'; +import { mockIPC, mockWindows } from '@tauri-apps/api/mocks'; +import App from '../src/App.svelte'; +import '../src/styles.css'; + +mockWindows('main'); +mockIPC((command) => { + if (command === 'get_settings') + return { + theme: 'light', + primaryColor: '#c98c5a', + textScale: 1, + pinned: true, + projectSortOrder: 'project-directory-asc', + statusSortOrder: 'submitted-at-desc', + statusGrouping: 'project-directory', + autoClearAfterMinutes: null, + maxStatusItemsPerProject: null, + undoLimit: 3, + }; + if (command === 'get_snapshot') + return { + revision: 1, + document: { version: 2, entries: [] }, + health: 'waiting', + path: null, + error: null, + modified_at: null, + }; + if (command === 'get_skill_installation') + return { + installed: false, + mcpRegistered: false, + path: '/fixture/skills', + targets: [], + additionalTargets: [], + agents: [], + additionalAgents: [], + }; + if (command === 'plugin:event|listen') return 1; + return null; +}); +mount(App, { target: document.body }); diff --git a/tests/lazy-route.spec.ts b/tests/lazy-route.spec.ts new file mode 100644 index 0000000..55a31b1 --- /dev/null +++ b/tests/lazy-route.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from 'playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.route('**/tests/lazy-route.html', (route) => + route.fulfill({ + contentType: 'text/html', + body: '', + }), + ); +}); + +test('loads settings only after navigating to its route', async ({ page }) => { + const settingsRequests: string[] = []; + page.on('request', (request) => { + if (request.url().includes('/src/settings/Settings.svelte')) + settingsRequests.push(request.url()); + }); + await page.goto('/tests/lazy-route.html'); + await expect(page.getByRole('region', { name: 'Project status' })).toBeVisible(); + expect(settingsRequests).toHaveLength(0); + await page.evaluate(() => { + window.location.hash = '/settings'; + }); + await expect(page.getByRole('heading', { name: 'Appearance', exact: true })).toBeVisible(); + expect(settingsRequests.length).toBeGreaterThan(0); +}); + +test('failed settings import offers reload and recovers', async ({ page }) => { + let fail = true; + await page.route('**/src/settings/Settings.svelte', (route) => { + if (fail) { + fail = false; + return route.abort(); + } + return route.continue(); + }); + await page.goto('/tests/lazy-route.html#/settings'); + await expect(page.getByRole('alert')).toHaveText('Settings could not be loaded.'); + await page.getByRole('button', { name: 'Reload settings' }).click(); + await expect(page.getByRole('heading', { name: 'Appearance', exact: true })).toBeVisible(); +}); diff --git a/tests/toolbar-update-fixture.js b/tests/toolbar-update-fixture.js new file mode 100644 index 0000000..65e35a1 --- /dev/null +++ b/tests/toolbar-update-fixture.js @@ -0,0 +1,54 @@ +import { mount, unmount } from 'svelte'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import Toolbar from './toolbar-update-fixture.svelte'; +import '../src/styles.css'; +const calls = []; +const deferred = new Set(); +const failures = new Set(); +const pending = new Map(); +let available = true; +const names = { + check: 'plugin:updater|check', + download: 'plugin:updater|download', + install: 'plugin:updater|install', + restart: 'restart_app', +}; +mockIPC((name, args) => { + calls.push({ name, rid: args?.rid, updateRid: args?.updateRid, bytesRid: args?.bytesRid }); + const kind = Object.keys(names).find((key) => names[key] === name); + if (failures.delete(kind)) throw new Error(`${kind} unavailable`); + const result = + kind === 'check' + ? available + ? { rid: 11, version: '2.6.0', currentVersion: '2.5.1' } + : null + : kind === 'download' + ? 22 + : null; + if (kind === 'download') { + args.onEvent.onmessage({ event: 'Started', data: { contentLength: 100 } }); + args.onEvent.onmessage({ event: 'Progress', data: { chunkLength: 45 } }); + } + if (deferred.delete(kind)) + return new Promise((resolve) => pending.set(kind, () => resolve(result))); + return result; +}); +const component = mount(Toolbar, { target: document.body }); +window.toolbarFixture = { + calls, + defer(kind) { + deferred.add(kind); + }, + resolve(kind) { + pending.get(kind)(); + }, + fail(kind) { + failures.add(kind); + }, + noUpdate() { + available = false; + }, + remove() { + return unmount(component); + }, +}; diff --git a/tests/toolbar-update-fixture.svelte b/tests/toolbar-update-fixture.svelte new file mode 100644 index 0000000..13d10a7 --- /dev/null +++ b/tests/toolbar-update-fixture.svelte @@ -0,0 +1,24 @@ + + + {}} + onTextScale={async () => {}} + onSettings={async () => {}} +/> diff --git a/tests/toolbar-update.spec.ts b/tests/toolbar-update.spec.ts new file mode 100644 index 0000000..ed7fb3c --- /dev/null +++ b/tests/toolbar-update.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from 'playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.route('**/tests/toolbar-update.html', (route) => + route.fulfill({ + contentType: 'text/html', + body: '', + }), + ); + await page.goto('/tests/toolbar-update.html'); +}); + +test('check, download, and install run separately and ready actions survive blur', async ({ + page, +}) => { + const check = page.getByRole('button', { name: 'Check for updates', exact: true }); + await expect(check.locator('svg')).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); + await expect(page.locator('.update-control')).toHaveCSS('opacity', '0'); + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await check.click(); + const download = page.getByRole('button', { name: 'Download update', exact: true }); + await expect(download).toBeEnabled(); + expect( + await page.evaluate( + () => window.toolbarFixture.calls.filter((x) => x.name === 'plugin:updater|download').length, + ), + ).toBe(0); + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); + await expect(page.locator('.update-control')).toHaveCSS('opacity', '1'); + await expect(page.getByRole('button', { name: 'Settings', exact: true })).toHaveCSS( + 'opacity', + '0', + ); + await download.click(); + const install = page.getByRole('button', { name: 'Install update', exact: true }); + await expect(install).toBeEnabled(); + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); + await expect(page.locator('.update-control')).toHaveCSS('opacity', '1'); + expect( + await page.evaluate( + () => window.toolbarFixture.calls.filter((x) => x.name === 'plugin:updater|install').length, + ), + ).toBe(0); + await install.click(); + await expect + .poll(() => + page.evaluate(() => window.toolbarFixture.calls.some((x) => x.name === 'restart_app')), + ) + .toBe(true); + const calls = await page.evaluate(() => window.toolbarFixture.calls); + expect(calls.filter((x) => x.name === 'plugin:updater|install')).toEqual([ + { name: 'plugin:updater|install', updateRid: 11, bytesRid: 22 }, + ]); + expect(calls.some((x) => x.name === 'plugin:updater|download_and_install')).toBe(false); +}); + +test('busy operations prevent duplicates and report download progress', async ({ page }) => { + await page.evaluate(() => window.toolbarFixture.defer('check')); + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Checking for updates', exact: true }), + ).toBeDisabled(); + await page.evaluate(() => window.toolbarFixture.resolve('check')); + await page.evaluate(() => window.toolbarFixture.defer('download')); + await page.getByRole('button', { name: 'Download update', exact: true }).click(); + const downloading = page.getByRole('button', { name: 'Downloading update', exact: true }); + await expect(downloading).toBeDisabled(); + await expect(downloading).toHaveAttribute('title', /45%/); + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); + await expect(page.locator('.update-control')).toHaveCSS('opacity', '1'); + await page.evaluate(() => window.toolbarFixture.resolve('download')); + await expect(page.getByRole('button', { name: 'Install update', exact: true })).toBeEnabled(); +}); + +test('each failure retains the right retry action; restart retry never reinstalls', async ({ + page, +}) => { + await page.evaluate(() => window.toolbarFixture.fail('check')); + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Check for updates', exact: true }), + ).toHaveAttribute('title', /check unavailable/); + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await page.evaluate(() => window.toolbarFixture.fail('download')); + await page.getByRole('button', { name: 'Download update', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Download update', exact: true })).toHaveAttribute( + 'title', + /download unavailable/, + ); + await page.getByRole('button', { name: 'Download update', exact: true }).click(); + await page.evaluate(() => window.toolbarFixture.fail('install')); + await page.getByRole('button', { name: 'Install update', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Install update', exact: true })).toHaveAttribute( + 'title', + /install unavailable/, + ); + await page.evaluate(() => window.toolbarFixture.fail('restart')); + await page.getByRole('button', { name: 'Install update', exact: true }).click(); + const restart = page.getByRole('button', { name: 'Restart to finish updating', exact: true }); + await expect(restart).toBeEnabled(); + await restart.click(); + expect( + await page.evaluate( + () => window.toolbarFixture.calls.filter((x) => x.name === 'plugin:updater|install').length, + ), + ).toBe(2); +}); + +test('no update keeps the check action available with feedback', async ({ page }) => { + await page.evaluate(() => window.toolbarFixture.noUpdate()); + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await expect( + page.getByRole('button', { name: 'Check for updates', exact: true }), + ).toHaveAttribute('title', /up to date/); +}); + +test('unmount releases downloaded bytes and updater resources', async ({ page }) => { + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await page.getByRole('button', { name: 'Download update', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Install update', exact: true })).toBeEnabled(); + await page.evaluate(() => window.toolbarFixture.remove()); + await expect + .poll(() => + page.evaluate(() => + window.toolbarFixture.calls + .filter((x) => x.name === 'plugin:resources|close') + .map((x) => x.rid), + ), + ) + .toEqual([22, 11]); +}); + +test('late check and download completions release resources after unmount', async ({ page }) => { + await page.evaluate(() => window.toolbarFixture.defer('check')); + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await page.evaluate(async () => { + await window.toolbarFixture.remove(); + window.toolbarFixture.resolve('check'); + }); + await expect + .poll(() => + page.evaluate(() => + window.toolbarFixture.calls + .filter((x) => x.name === 'plugin:resources|close') + .map((x) => x.rid), + ), + ) + .toEqual([11]); + await page.reload(); + await page.getByRole('button', { name: 'Check for updates', exact: true }).click(); + await page.evaluate(() => window.toolbarFixture.defer('download')); + await page.getByRole('button', { name: 'Download update', exact: true }).click(); + await page.evaluate(async () => { + await window.toolbarFixture.remove(); + window.toolbarFixture.resolve('download'); + }); + await expect + .poll(() => + page.evaluate(() => + window.toolbarFixture.calls + .filter((x) => x.name === 'plugin:resources|close') + .map((x) => x.rid), + ), + ) + .toEqual([22, 11]); +}); diff --git a/tests/update-schedule.test.ts b/tests/update-schedule.test.ts index 9519b8c..efc9ad5 100644 --- a/tests/update-schedule.test.ts +++ b/tests/update-schedule.test.ts @@ -58,3 +58,31 @@ test('disposal during an in-flight check prevents rescheduling', async () => { await Promise.resolve(); assert.equal(scheduled, false); }); + +test('deferred launch leaves the initial check to the user and schedules background checks', async () => { + let checks = 0; + let callback: () => void = () => {}; + const delays: number[] = []; + const stop = startUpdateSchedule( + async () => { + checks++; + }, + { + setTimeout: (fn, delay) => { + callback = fn; + delays.push(delay); + return 1 as unknown as ReturnType; + }, + clearTimeout: () => {}, + random: () => 0, + }, + { immediate: false }, + ); + assert.equal(checks, 0); + assert.deepEqual(delays, [10_801_000]); + callback(); + await Promise.resolve(); + assert.equal(checks, 1); + assert.equal(delays.length, 2); + stop(); +}); diff --git a/tests/updater-lifecycle-fixture.js b/tests/updater-lifecycle-fixture.js new file mode 100644 index 0000000..818923a --- /dev/null +++ b/tests/updater-lifecycle-fixture.js @@ -0,0 +1,43 @@ +import { mount, unmount } from 'svelte'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import UpdateSettings from '../src/settings/UpdateSettings.svelte'; +let releaseCheck; +let releaseInstall; +const closed = []; +let delayed = false; +mockIPC((name, args) => { + if (name === 'plugin:app|version') return '2.5.1'; + if (name === 'plugin:updater|check') { + const metadata = { rid: 7, version: '2.6.0', currentVersion: '2.5.1', body: 'Test update' }; + if (delayed) + return new Promise((resolve) => { + releaseCheck = () => resolve(metadata); + }); + return metadata; + } + if (name === 'plugin:resources|close') { + closed.push(args.rid); + return null; + } + if (name === 'plugin:updater|download_and_install') + return new Promise((resolve) => { + releaseInstall = resolve; + }); + return null; +}); +const component = mount(UpdateSettings, { target: document.body, props: { updatesEnabled: true } }); +window.updaterFixture = { + closed, + delayCheck() { + delayed = true; + }, + resolveCheck() { + releaseCheck(); + }, + resolveInstall() { + releaseInstall(); + }, + remove() { + return unmount(component); + }, +}; diff --git a/tests/updater-lifecycle.spec.ts b/tests/updater-lifecycle.spec.ts new file mode 100644 index 0000000..6bd18c7 --- /dev/null +++ b/tests/updater-lifecycle.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from 'playwright/test'; +test.beforeEach(async ({ page }) => { + await page.route('**/tests/updater-lifecycle.html', (route) => + route.fulfill({ + contentType: 'text/html', + body: '', + }), + ); + await page.goto('/tests/updater-lifecycle.html'); + await expect(page.getByRole('button', { name: 'Check for updates' })).toBeEnabled(); +}); +test('unmount closes an available update exactly once', async ({ page }) => { + await page.getByRole('button', { name: 'Check for updates' }).click(); + await expect(page.getByRole('button', { name: 'Download and install' })).toBeVisible(); + await page.evaluate(() => window.updaterFixture.remove()); + await expect.poll(() => page.evaluate(() => window.updaterFixture.closed)).toEqual([7]); +}); +test('late check results close their resource after unmount', async ({ page }) => { + await page.evaluate(() => window.updaterFixture.delayCheck()); + await page.getByRole('button', { name: 'Check for updates' }).click(); + await page.evaluate(async () => { + await window.updaterFixture.remove(); + window.updaterFixture.resolveCheck(); + }); + await expect.poll(() => page.evaluate(() => window.updaterFixture.closed)).toEqual([7]); +}); +test('in-flight install keeps its resource until completion after unmount', async ({ page }) => { + await page.getByRole('button', { name: 'Check for updates' }).click(); + await page.getByRole('button', { name: 'Download and install' }).click(); + await expect(page.getByText('0 KB downloaded')).toBeVisible(); + await page.evaluate(() => window.updaterFixture.remove()); + expect(await page.evaluate(() => window.updaterFixture.closed)).toEqual([]); + await page.evaluate(() => window.updaterFixture.resolveInstall()); + await expect.poll(() => page.evaluate(() => window.updaterFixture.closed)).toEqual([7]); +}); diff --git a/tests/viewer-controller.test.ts b/tests/viewer-controller.test.ts index 8d99740..8a1a662 100644 --- a/tests/viewer-controller.test.ts +++ b/tests/viewer-controller.test.ts @@ -1,6 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { ViewerController, type TimerApi, type ViewerMotion } from '../src/viewer/controller.ts'; +import { + MAX_TIMEOUT_MS, + expirySoon, + ViewerController, + type TimerApi, + type ViewerMotion, +} from '../src/viewer/controller.ts'; import type { Settings, Snapshot, StatusEntry } from '../src/shared/types.ts'; const settings: Settings = { @@ -54,7 +60,15 @@ function clock(reduced = false) { nextDue = [...timers.entries()].sort((a, b) => a[1].due - b[1].due)[0]; } } - return { api, advance, pending: () => timers.size }; + return { + api, + advance, + pending: () => timers.size, + delays: () => [...timers.values()].map(({ due }) => due - now), + jump: (ms: number) => { + now += ms; + }, + }; } test('queued newest revision commits after the hold and notifies subscribers', async () => { @@ -716,3 +730,84 @@ test('undo requested during dismissal waits for removal and then restores', asyn assert.equal(controller.state.undoCount, 0); controller.dispose(); }); + +async function expiryFixture(minutes: number, pinned = false) { + const fake = clock(true); + const controller = new ViewerController(fake.api); + const submittedAt = new Date(fake.api.now()).toISOString(); + const fresh: StatusEntry = { + ...entry, + updated_at: submittedAt, + points: [{ ...entry.points[0], submitted_at: submittedAt, pinned }], + }; + controller.acceptSettings({ ...settings, autoClearAfterMinutes: minutes }); + controller.acceptSnapshot(snapshot(1, [fresh])); + fake.advance(0); + await microtasks(); + return { fake, controller, fresh }; +} + +test('30-day expiry is capped and rearmed for the remaining warning deadline', async () => { + const { fake, controller, fresh } = await expiryFixture(43_200); + assert.deepEqual(fake.delays(), [MAX_TIMEOUT_MS]); + fake.advance(MAX_TIMEOUT_MS); + const remaining = 43_200 * 60_000 - 10 * 60_000 - MAX_TIMEOUT_MS; + assert.deepEqual(fake.delays(), [remaining]); + assert.equal(controller.expirySoon(fresh.updated_at), false); + fake.advance(remaining); + assert.equal(controller.expirySoon(fresh.updated_at), true); + assert.equal(fake.pending(), 0); + controller.dispose(); +}); + +test('expiry recalculates after forward and backward wall-clock changes', async () => { + const { fake, controller, fresh } = await expiryFixture(20); + fake.jump(-5 * 60_000); + controller.refreshNow(); + assert.deepEqual(fake.delays(), [15 * 60_000]); + fake.jump(30 * 60_000); + controller.refreshNow(); + assert.equal(controller.expirySoon(fresh.updated_at), true); + assert.equal(fake.pending(), 0); + controller.dispose(); +}); + +test('pinned and overdue points do not schedule warning timers', async () => { + const pinned = await expiryFixture(43_200, true); + assert.equal(pinned.fake.pending(), 0); + pinned.controller.dispose(); + const overdue = await expiryFixture(1); + assert.equal(overdue.fake.pending(), 0); + assert.equal(overdue.controller.expirySoon(overdue.fresh.updated_at), true); + overdue.controller.dispose(); +}); + +test('snapshot replacement, disabling retention and disposal cancel the expiry timer', async () => { + const { fake, controller } = await expiryFixture(43_200); + controller.acceptSnapshot(snapshot(2, [])); + fake.advance(0); + await microtasks(); + assert.equal(fake.pending(), 0); + controller.acceptSnapshot( + snapshot(3, [{ ...entry, updated_at: new Date(fake.api.now()).toISOString() }]), + ); + fake.advance(0); + await microtasks(); + assert.equal(fake.pending(), 1); + controller.acceptSettings(settings); + fake.advance(0); + await microtasks(); + assert.equal(fake.pending(), 0); + controller.acceptSettings({ ...settings, autoClearAfterMinutes: 43_200 }); + assert.equal(fake.pending(), 1); + controller.dispose(); + assert.equal(fake.pending(), 0); +}); + +test('expiry helper preserves the ten-minute warning boundary and disabled retention', () => { + const at = '2026-11-12T00:00:00Z'; + const warning = Date.parse(at) + 10 * 60_000; + assert.equal(expirySoon(at, 20, warning - 1), false); + assert.equal(expirySoon(at, 20, warning), true); + assert.equal(expirySoon(at, null, warning), false); +}); diff --git a/tests/viewer-health-fixture.js b/tests/viewer-health-fixture.js new file mode 100644 index 0000000..f9c7c09 --- /dev/null +++ b/tests/viewer-health-fixture.js @@ -0,0 +1,131 @@ +import { mount } from 'svelte'; +import { mockIPC, mockWindows } from '@tauri-apps/api/mocks'; +import Viewer from '../src/viewer/Viewer.svelte'; +import RetentionSettings from '../src/settings/RetentionSettings.svelte'; +import AgentIntegrations from '../src/integrations/AgentIntegrations.svelte'; +import '../src/styles.css'; + +const listeners = new Map(); +const settings = { + theme: 'light', + primaryColor: '#c98c5a', + textScale: 1, + pinned: true, + projectSortOrder: 'project-directory-asc', + statusSortOrder: 'submitted-at-desc', + statusGrouping: 'project-directory', + autoClearAfterMinutes: null, + maxStatusItemsPerProject: null, + undoLimit: 3, +}; +let revision = 1; +let entries = []; +let health = 'waiting'; +let removed = []; +const at = new Date().toISOString(); +const snapshot = () => ({ + revision: revision++, + document: { version: 2, entries }, + health, + error: health === 'stale' ? 'File unavailable' : null, + path: '/fixture/status.json', + modified_at: at, + last_successful_read_at: at, +}); +const calls = []; +mockWindows('main'); +mockIPC((name, args) => { + calls.push({ name, args }); + if (name === 'plugin:event|listen') { + listeners.set(args.event, args.handler); + return listeners.size; + } + if (name === 'get_settings') return settings; + if (name === 'get_snapshot') return snapshot(); + if (name === 'get_recently_removed') return removed; + if (name === 'restore_removed') { + removed = removed.filter((x) => x.id !== args.id); + return null; + } + if (name === 'preview_retention') return 2; + if (name === 'get_skill_installation') + return { + installed: false, + mcpRegistered: true, + path: '/fixture/skill', + targets: [], + additionalTargets: [], + additionalAgents: [], + agents: [ + { + id: 'codex', + name: 'Codex', + mcpRegistered: true, + configurationValid: true, + skillInstalled: false, + available: true, + configPath: '/fixture/config.toml', + error: null, + }, + ], + }; + if (name === 'test_mcp_connection') + return { + status: 'connected', + message: 'A new connection responded. Restart existing sessions after updating.', + }; + return null; +}); +window.fixture = { + calls, + set(healthValue, nextEntries) { + health = healthValue; + entries = nextEntries; + window.__TAURI_INTERNALS__.runCallback(listeners.get('source-update'), { payload: snapshot() }); + }, + entries() { + return [1, 2].map((i) => ({ + folder_name: 'project', + session_id: `session-${i}`, + branch: 'main', + updated_at: at, + points: [ + { + id: `point-${i}`, + text: `Point ${i}`, + done: false, + pinned: false, + link: null, + submitted_at: at, + }, + ], + })); + }, + removed() { + removed = [ + { + id: 'removed-1', + entry: this.entries()[0], + point_index: 0, + removed_at: at, + reason: 'Time-based deletion', + }, + ]; + }, +}; +const mode = new URLSearchParams(location.search).get('mode'); +if (mode === 'retention') + mount(RetentionSettings, { + target: document.body, + props: { + settings, + saving: false, + onSaved: () => {}, + onError: console.error, + onSortChange: () => {}, + onStatusSortChange: () => {}, + }, + }); +else if (mode === 'integrations') + mount(AgentIntegrations, { target: document.body, props: { onError: console.error } }); +else mount(Viewer, { target: document.body }); diff --git a/tests/viewer-health.spec.ts b/tests/viewer-health.spec.ts new file mode 100644 index 0000000..8933a08 --- /dev/null +++ b/tests/viewer-health.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from 'playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.route('**/tests/health.html*', (route) => + route.fulfill({ + contentType: 'text/html', + body: '', + }), + ); +}); +test('empty, healthy, stale and paused updates remain understandable', async ({ page }) => { + await page.goto('/tests/health.html'); + await expect(page.getByText('Waiting for updates', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Set up agent integration' })).toBeVisible(); + await page.evaluate(() => window.fixture.set('current', window.fixture.entries())); + await expect(page.getByText('Point 1', { exact: true })).toBeVisible(); + await expect(page.locator('.session-label').filter({ hasText: 'Session 1' })).toBeVisible(); + await page.evaluate(() => { + const node = document.querySelector('.point-text'); + const range = document.createRange(); + range.selectNodeContents(node); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + }); + await expect(page.getByRole('button', { name: 'Resume updates' })).toBeVisible(); + await page.evaluate(() => window.fixture.set('stale', [])); + await expect(page.getByText('Updates are stale', { exact: true })).toBeVisible(); + await expect(page.getByText('Point 1', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Resume updates' }).click(); + await expect(page.getByText('Point 1', { exact: true })).toHaveCount(0); +}); +test('collapsed groups survive reload and archive restoration is explicit', async ({ page }) => { + await page.goto('/tests/health.html'); + await expect(page.getByRole('button', { name: 'Set up agent integration' })).toBeVisible(); + await page.evaluate(() => window.fixture.set('current', window.fixture.entries())); + await page.getByRole('button', { name: 'Collapse project', exact: true }).focus(); + await page.getByRole('button', { name: 'Collapse project', exact: true }).press('Enter'); + await expect( + page.locator('.motion-row').filter({ has: page.getByText('Point 1', { exact: true }) }), + ).toHaveAttribute('aria-hidden', 'true'); + await page.reload(); + await expect(page.getByRole('button', { name: 'Set up agent integration' })).toBeVisible(); + await page.evaluate(() => { + window.fixture.set('current', window.fixture.entries()); + window.fixture.removed(); + }); + const expand = page.getByRole('button', { name: 'Expand project', exact: true }); + await expect(expand).toBeVisible(); + await expand.hover(); + await expect(expand).toHaveCSS('opacity', '1'); + await expand.click(); + await expect(page.getByRole('button', { name: 'Collapse project', exact: true })).toBeVisible(); + await page.getByText('Recently removed', { exact: true }).click(); + await page.getByRole('button', { name: 'Restore and pin Point 1' }).click(); + await expect(page.getByText('No recently removed items.')).toBeVisible(); +}); +test('enabling deletion requires a preview and explicit apply', async ({ page }) => { + await page.goto('/tests/health.html?mode=retention'); + await page.getByRole('checkbox', { name: 'Time-based deletion' }).check(); + await expect(page.getByRole('button', { name: 'Review removal' })).toBeVisible(); + expect( + await page.evaluate(() => + window.fixture.calls.some((x) => x.name === 'set_auto_clear_after_minutes'), + ), + ).toBe(false); + await page.getByRole('button', { name: 'Review removal' }).click(); + await expect(page.getByRole('status')).toContainText('About 2 items would'); + await page.getByRole('button', { name: 'Apply deletion rule' }).click(); + await expect + .poll(() => + page.evaluate( + () => window.fixture.calls.filter((x) => x.name === 'set_auto_clear_after_minutes').length, + ), + ) + .toBe(1); +}); +test('configured integrations offer read-only connection testing and repair', async ({ page }) => { + await page.goto('/tests/health.html?mode=integrations'); + await page.getByRole('button', { name: 'Test connection' }).click(); + await expect(page.getByRole('status')).toContainText('Connected:'); + await expect(page.getByRole('button', { name: 'Repair MCP' })).toBeVisible(); +});