From 5134639bbd530e75d8d547f17b5d3c840eabf1a1 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 10 Apr 2026 14:00:53 -0700 Subject: [PATCH 1/5] spec for the auto-update component --- docs/specs/auto-update.md | 204 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/specs/auto-update.md diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md new file mode 100644 index 00000000..70d1deaf --- /dev/null +++ b/docs/specs/auto-update.md @@ -0,0 +1,204 @@ +# Auto-Update Spec + +## Goal + +When a new version of the standalone app is available, download it silently in the background and install it when the app quits. Show a non-intrusive banner so the user knows an update is pending. Checking for updates is on by default. + +## Non-goals + +- No mid-session relaunches. The update installs at quit time — terminal sessions are never interrupted. +- No update checks in the VSCode extension (Marketplace handles that). +- No settings UI for update preferences in v1 — just the banner and a way to dismiss. + +## Infrastructure already in place + +The deploy pipeline (see `deploy.md`) already produces everything the Tauri updater needs: + +| Piece | Status | +|-------|--------| +| `tauri-plugin-updater` Rust crate | Registered in `lib.rs` | +| Updater endpoint (`mouseterm.com/standalone-latest.json`) | Configured in `tauri.conf.json` | +| Ed25519 public key | Configured in `tauri.conf.json` | +| Signed update bundles + `.sig` files | Generated by `sign-and-deploy.sh` | + +What's missing: the JS dependency (`@tauri-apps/plugin-updater`) and all frontend code. + +## Update check lifecycle + +``` +app launch + │ + ├─ wait 5 seconds (let the UI settle) + │ + ├─ check(endpoint) ──→ no update ──→ done (silent) + │ │ + │ └─→ update available + │ │ + │ └─→ download silently in background + │ │ + │ ├─→ download succeeds → show banner, hold Update object + │ │ + │ └─→ download fails → log error, done (silent) + │ + ... user works normally ... + │ + user quits the app + │ + ├─ pending update? ──→ no ──→ exit normally + │ │ + │ └─→ yes ──→ install() + │ │ + │ ├─ success → save "updated-from" marker to localStorage, exit + │ │ │ + │ │ ├─ Windows: NSIS installer runs (force-quits app automatically) + │ │ └─ macOS/Linux: binary replaced in place, app exits normally + │ │ + │ └─ failure → save "update-failed" marker to localStorage, log error, exit normally + │ + next launch + │ + ├─ "updated-from" marker? ──→ show "Updated to vX.Y.Z" banner (auto-dismisses after 10s), clear marker + │ + ├─ "update-failed" marker? ──→ show "Update failed — will retry next launch" banner, clear marker + │ + └─ neither ──→ normal launch (proceed to update check after 5s) +``` + +### Offline / network failure + +If `check()` or `download()` fails (offline, timeout, DNS error), the app does nothing — no banner, no error, no retry. The next launch gets another chance. + +### Post-install feedback + +The update lifecycle doesn't end at `install()` — the user needs closure on next launch. + +**Success case:** Before exiting, write `{ "from": "0.4.0", "to": "0.5.0" }` to a `localStorage` key (`mouseterm:update-result`). On next launch, if this marker exists, show a banner: + +``` + Updated to v0.5.0 — from v0.4.0. [Changelog] [×] +``` + +This banner auto-dismisses after 10 seconds and is also dismissible via `[×]`. After showing (or auto-dismissing), clear the marker. The normal update check (5s delay) proceeds independently. + +**Failure case:** If `install()` throws, write `{ "failed": true, "version": "0.5.0", "error": "" }` to the same `localStorage` key. On next launch, show a persistent banner: + +``` + Update to v0.5.0 failed — will retry next launch. [×] +``` + +Clear the marker after showing. The next update check will re-download and try again on the following quit. No retry loop within the same session — one attempt per launch, same as the initial check. + +**Why `localStorage`?** Tauri's webview `localStorage` persists across launches and doesn't require Rust-side state or filesystem writes. It also means the marker is scoped to the webview and cleaned up automatically if the user resets app data. + +## Banner + +After the update is downloaded, show a banner at the top of the window: + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Update downloaded (v0.5.0) — will install when you quit. [Changelog] [×] │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- The banner sits above the terminal content, not overlapping it — it pushes content down. +- `[Changelog]` — opens `https://mouseterm.com/changelog` in the default browser. +- `[×]` — dismisses the banner for **this session only**. The update still installs on quit. The banner does not reappear until next launch (where it won't appear at all, because the app is already updated). This means [×] is always a lightweight, low-stakes action — it hides a notification, it doesn't affect update behavior. + +### Why no "skip" or "install now" + +- **No skip**: The update is already downloaded and will install on quit. There's nothing to skip — the user can't end up on a stale version unless they never quit the app. +- **No install now**: Installing requires closing all terminal sessions (on Windows, the NSIS installer force-quits the app). Offering an "install now" button invites accidental session loss. Quit-time install means the user has already decided to close their sessions. + +## Platform behavior at quit + +| Platform | What `install()` does | App exit | +|----------|----------------------|----------| +| Windows | Launches NSIS installer, which force-kills the app | Automatic (NSIS handles it) | +| macOS | Replaces the `.app` bundle in place | App exits normally after `install()` returns | +| Linux | Replaces the AppImage in place | App exits normally after `install()` returns | + +On Windows, use the `on_before_exit` hook to perform any cleanup (e.g., shutting down the sidecar) before the NSIS installer kills the process: + +```rust +app.updater_builder() + .on_before_exit(|| { + // cleanup: shut down sidecar, save state, etc. + }) + .build()?; +``` + +Configure the NSIS installer to run without user interaction: + +```json +{ + "plugins": { + "updater": { + "windows": { + "installMode": "passive" + } + } + } +} +``` + +`"passive"` shows a small progress bar but requires no clicks. This is the Tauri default and recommended mode. + +## Tauri API usage + +The JS side uses `@tauri-apps/plugin-updater` (must be added to `standalone/package.json`): + +```ts +import { check } from '@tauri-apps/plugin-updater'; + +// On startup (after 5s delay) +const update = await check(); +if (update) { + await update.download((progress) => { + // progress.event: 'Started' | 'Progress' | 'Finished' + }); + // Store the update object — install() will be called at quit time +} +``` + +The `Update` object must be held in memory for the duration of the session. When the app is closing: + +```ts +// In the quit/close handler +if (pendingUpdate) { + try { + localStorage.setItem('mouseterm:update-result', JSON.stringify({ + from: currentVersion, + to: pendingUpdate.version, + })); + await pendingUpdate.install(); + // On macOS/Linux, this returns and the app exits normally. + // On Windows, the NSIS installer force-kills the process (on_before_exit fires first). + } catch (e) { + localStorage.setItem('mouseterm:update-result', JSON.stringify({ + failed: true, + version: pendingUpdate.version, + error: String(e), + })); + // Log and exit normally — will retry next launch. + console.error('Update install failed:', e); + } +} +``` + +The `check()` call handles endpoint fetching, version comparison, and signature verification using the pubkey in `tauri.conf.json`. No custom Rust commands needed. + +## Where code lives + +| File | Responsibility | +|------|----------------| +| `standalone/src/updater.ts` | Update check, background download, quit-time install, post-install markers, state | +| `standalone/src/UpdateBanner.tsx` | Banner React component | +| `standalone/src/App.tsx` | Mounts `` at the top of the app | + +The updater module is standalone-only — it does not go into `lib/` because the VSCode extension and website have no use for it. + +## Setup required + +1. `pnpm --filter mouseterm-standalone add @tauri-apps/plugin-updater @tauri-apps/plugin-process` +2. Add `"process"` to the `plugins` allowlist in `tauri.conf.json` if not already present. +3. Add `"installMode": "passive"` to the updater Windows config in `tauri.conf.json`. From b33211d43216ede43e6bce1bc14371372bf95d4a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 10 Apr 2026 14:41:58 -0700 Subject: [PATCH 2/5] First cut at an implementation --- lib/src/components/Pond.tsx | 2 +- lib/src/index.css | 6 + lib/src/stories/UpdateBanner.stories.tsx | 71 +++++ pnpm-lock.yaml | 275 +++++++++++++++++- standalone/package.json | 9 +- .../src-tauri/capabilities/default.json | 3 +- standalone/src-tauri/tauri.conf.json | 5 +- standalone/src/UpdateBanner.tsx | 56 ++++ standalone/src/main.tsx | 11 + standalone/src/updater.test.ts | 219 ++++++++++++++ standalone/src/updater.ts | 152 ++++++++++ standalone/vitest.config.ts | 8 + 12 files changed, 809 insertions(+), 8 deletions(-) create mode 100644 lib/src/stories/UpdateBanner.stories.tsx create mode 100644 standalone/src/UpdateBanner.tsx create mode 100644 standalone/src/updater.test.ts create mode 100644 standalone/src/updater.ts create mode 100644 standalone/vitest.config.ts diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 7f4fc47b..3f4d0ff5 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -1770,7 +1770,7 @@ export function Pond({ -
+
{/* Dockview */}
diff --git a/lib/src/index.css b/lib/src/index.css index 48dce911..dd0a8ba9 100644 --- a/lib/src/index.css +++ b/lib/src/index.css @@ -10,6 +10,12 @@ body { font-family: var(--mt-font-family); } +#root { + display: flex; + flex-direction: column; + height: 100vh; +} + /* --- Dockview overrides: flatten tab bar into a pane header --- */ /* Each group has exactly one panel (tab stacking is disabled), diff --git a/lib/src/stories/UpdateBanner.stories.tsx b/lib/src/stories/UpdateBanner.stories.tsx new file mode 100644 index 00000000..86e2b3e8 --- /dev/null +++ b/lib/src/stories/UpdateBanner.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { UpdateBanner, type UpdateBannerState } from '../../../standalone/src/UpdateBanner'; + +function UpdateBannerStory({ state }: { state: UpdateBannerState }) { + return ( +
+ console.log('Dismiss')} + onOpenChangelog={() => console.log('Open changelog')} + /> +
+ ); +} + +const meta: Meta = { + title: 'Components/UpdateBanner', + component: UpdateBannerStory, +}; + +export default meta; +type Story = StoryObj; + +export const Downloaded: Story = { + args: { + state: { status: 'downloaded', version: '0.5.0' }, + }, +}; + +export const PostUpdateSuccess: Story = { + args: { + state: { status: 'post-update-success', from: '0.4.0', to: '0.5.0' }, + }, +}; + +export const PostUpdateFailure: Story = { + args: { + state: { status: 'post-update-failure', version: '0.5.0' }, + }, +}; + +export const Idle: Story = { + args: { + state: { status: 'idle' }, + }, +}; + +export const Dismissed: Story = { + args: { + state: { status: 'dismissed' }, + }, +}; + +export const LongVersionString: Story = { + args: { + state: { status: 'downloaded', version: '1.23.456-beta.7+build.2025.04.10' }, + }, +}; + +export const NarrowViewport: Story = { + args: { + state: { status: 'downloaded', version: '0.5.0' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6613a990..f40b9cc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ importers: version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vitest: specifier: ^4.1.0 - version: 4.1.1(jsdom@24.1.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) standalone: dependencies: @@ -83,9 +83,15 @@ importers: '@tauri-apps/api': specifier: ^2.0.0 version: 2.10.1 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 '@tauri-apps/plugin-shell': specifier: ^2.0.0 version: 2.3.5 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -123,6 +129,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.2.0 version: 4.3.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + jsdom: + specifier: ^29.0.2 + version: 29.0.2 tailwindcss: specifier: ^4.0.0 version: 4.2.2 @@ -132,6 +141,9 @@ importers: vite: specifier: ^7.3.0 version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.1 + version: 4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) standalone/sidecar: dependencies: @@ -221,6 +233,17 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asamuzakjp/css-color@5.1.9': + resolution: {integrity: sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.9': + resolution: {integrity: sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -342,10 +365,18 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} engines: {node: '>=18'} @@ -353,6 +384,13 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-color-parser@3.1.0': resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} engines: {node: '>=18'} @@ -360,16 +398,41 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} peerDependencies: '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -691,6 +754,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1364,9 +1436,15 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + '@tauri-apps/plugin-shell@2.3.5': resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1631,6 +1709,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -1773,6 +1854,10 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -1791,6 +1876,10 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2127,6 +2216,10 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html5parser@2.0.2: resolution: {integrity: sha512-L0y+IdTVxHsovmye8MBtFgBvWZnq1C9WnI/SmJszxoQjmUH1psX2uzDk21O5k5et6udxdGjwxkbmT9eVRoG05w==} @@ -2245,6 +2338,15 @@ packages: canvas: optional: true + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2424,6 +2526,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -2575,6 +2680,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3009,6 +3117,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -3021,10 +3136,18 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -3229,6 +3352,10 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3241,10 +3368,18 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3333,6 +3468,22 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@asamuzakjp/css-color@5.1.9': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.0.9': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -3524,13 +3675,24 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@csstools/color-helpers@5.1.0': {} + '@csstools/color-helpers@6.0.2': {} + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/color-helpers': 5.1.0 @@ -3538,12 +3700,29 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -3716,6 +3895,8 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@exodus/bytes@1.15.0': {} + '@isaacs/cliui@9.0.0': {} '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': @@ -4241,10 +4422,18 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-shell@2.3.5': dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -4568,6 +4757,10 @@ snapshots: baseline-browser-mapping@2.10.10: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -4721,6 +4914,11 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} css.escape@1.5.1: {} @@ -4737,6 +4935,13 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5103,6 +5308,12 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html5parser@2.0.2: dependencies: tslib: 2.8.1 @@ -5233,6 +5444,32 @@ snapshots: - supports-color - utf-8-validate + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.9 + '@asamuzakjp/dom-selector': 7.0.9 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.5 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-schema-traverse@1.0.0: {} @@ -5387,6 +5624,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} merge2@1.4.1: {} @@ -5534,6 +5773,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -6020,6 +6263,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + tmp@0.2.5: {} to-regex-range@5.0.1: @@ -6033,10 +6282,18 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + tr46@5.1.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-dedent@2.2.0: {} tsconfig-paths@4.2.0: @@ -6147,7 +6404,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 - vitest@4.1.1(jsdom@24.1.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): + vitest@4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): dependencies: '@vitest/expect': 4.1.1 '@vitest/mocker': 4.1.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) @@ -6170,7 +6427,7 @@ snapshots: vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: - jsdom: 24.1.3 + jsdom: 29.0.2 transitivePeerDependencies: - msw @@ -6180,6 +6437,8 @@ snapshots: webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -6188,11 +6447,21 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/standalone/package.json b/standalone/package.json index ec0dcf6d..b0e3b632 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -7,12 +7,15 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dockview-react": "^5.1.0", @@ -27,8 +30,10 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react-swc": "^4.2.0", + "jsdom": "^29.0.2", "tailwindcss": "^4.0.0", "typescript": "^5.9.0", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.1.1" } } diff --git a/standalone/src-tauri/capabilities/default.json b/standalone/src-tauri/capabilities/default.json index 116f694f..b7183073 100644 --- a/standalone/src-tauri/capabilities/default.json +++ b/standalone/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "core:default", "shell:allow-spawn", "shell:allow-stdin-write", - "shell:allow-kill" + "shell:allow-kill", + "shell:allow-open" ] } diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 0972db12..aafff23e 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -47,7 +47,10 @@ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNUE3RThENTQxQTY0REIKUldUYlpCcFVqWDVhckxRQjBFbGw4anhJMUZ5L2VEU0pGNTluS1hPR0F1OGc1T3BUYTVjbHd0WG0K", "endpoints": [ "https://mouseterm.com/standalone-latest.json" - ] + ], + "windows": { + "installMode": "passive" + } } } } diff --git a/standalone/src/UpdateBanner.tsx b/standalone/src/UpdateBanner.tsx new file mode 100644 index 00000000..44ef0c91 --- /dev/null +++ b/standalone/src/UpdateBanner.tsx @@ -0,0 +1,56 @@ +import { XIcon } from '@phosphor-icons/react'; + +export type UpdateBannerState = + | { status: 'idle' } + | { status: 'downloaded'; version: string } + | { status: 'dismissed' } + | { status: 'post-update-success'; from: string; to: string } + | { status: 'post-update-failure'; version: string }; + +interface UpdateBannerProps { + state: UpdateBannerState; + onDismiss: () => void; + onOpenChangelog: () => void; +} + +export function UpdateBanner({ state, onDismiss, onOpenChangelog }: UpdateBannerProps) { + if (state.status === 'idle' || state.status === 'dismissed') return null; + + let message: string; + let showChangelog = false; + + switch (state.status) { + case 'downloaded': + message = `Update downloaded (v${state.version}) \u2014 will install when you quit.`; + showChangelog = true; + break; + case 'post-update-success': + message = `Updated to v${state.to} \u2014 from v${state.from}.`; + showChangelog = true; + break; + case 'post-update-failure': + message = `Update to v${state.version} failed \u2014 will retry next launch.`; + break; + } + + return ( +
+ {message} + {showChangelog && ( + + )} + +
+ ); +} diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index e270cf42..1fedda1a 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -5,19 +5,30 @@ import { reconnectFromInit } from "mouseterm-lib/lib/reconnect"; import App from "mouseterm-lib/App"; import "mouseterm-lib/index.css"; import { TauriAdapter } from "./tauri-adapter"; +import { UpdateBanner } from "./UpdateBanner"; +import { startUpdateCheck, useUpdateState, dismissBanner, openChangelog } from "./updater"; // Initialize Tauri platform adapter before rendering const platform = new TauriAdapter(); setPlatform(platform); +function ConnectedUpdateBanner() { + const state = useUpdateState(); + return ; +} + // Await init() first to register event listeners before reconnecting async function bootstrap() { await platform.init(); const { initAlarmStateReceiver } = await import("mouseterm-lib/lib/terminal-registry"); initAlarmStateReceiver(); const result = await reconnectFromInit(platform); + + startUpdateCheck(); + createRoot(document.getElementById("root")!).render( + , ); diff --git a/standalone/src/updater.test.ts b/standalone/src/updater.test.ts new file mode 100644 index 00000000..4e56498b --- /dev/null +++ b/standalone/src/updater.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks --- + +const mocks = vi.hoisted(() => ({ + check: vi.fn(), + getVersion: vi.fn(), + onCloseRequested: vi.fn(), + windowClose: vi.fn(), + shellOpen: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: mocks.check, +})); + +vi.mock('@tauri-apps/api/app', () => ({ + getVersion: mocks.getVersion, +})); + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + onCloseRequested: mocks.onCloseRequested, + close: mocks.windowClose, + }), +})); + +vi.mock('@tauri-apps/plugin-shell', () => ({ + open: mocks.shellOpen, +})); + +// --- Helpers --- + +const STORAGE_KEY = 'mouseterm:update-result'; + +function makeUpdate(version = '0.5.0') { + return { + version, + download: vi.fn(async () => {}), + install: vi.fn(async () => {}), + }; +} + +// Import after mocks +import { startUpdateCheck, openChangelog, _resetForTesting } from './updater'; + +describe('updater', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + localStorage.clear(); + _resetForTesting(); + mocks.getVersion.mockResolvedValue('0.4.0'); + mocks.check.mockResolvedValue(null); + mocks.onCloseRequested.mockResolvedValue(vi.fn()); + mocks.windowClose.mockResolvedValue(undefined); + mocks.shellOpen.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('post-install markers', () => { + it('reads a success marker and clears it from localStorage', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ from: '0.3.0', to: '0.4.0' })); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(0); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('reads a failure marker and clears it from localStorage', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ failed: true, version: '0.5.0', error: 'oops' })); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(0); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('skips update check when a post-install marker is present', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ from: '0.3.0', to: '0.4.0' })); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(mocks.check).not.toHaveBeenCalled(); + }); + }); + + describe('update check', () => { + it('waits 5 seconds before checking', async () => { + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(4_999); + expect(mocks.check).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(mocks.check).toHaveBeenCalledOnce(); + }); + + it('downloads when an update is available', async () => { + const update = makeUpdate(); + mocks.check.mockResolvedValue(update); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + // Let check() and download() resolve + await vi.advanceTimersByTimeAsync(0); + + expect(update.download).toHaveBeenCalledOnce(); + }); + + it('does not crash on check failure', async () => { + mocks.check.mockRejectedValue(new Error('network')); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + // No throw, no crash + expect(mocks.check).toHaveBeenCalledOnce(); + }); + + it('does not crash on download failure', async () => { + const update = makeUpdate(); + update.download.mockRejectedValue(new Error('disk full')); + mocks.check.mockResolvedValue(update); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + expect(update.download).toHaveBeenCalledOnce(); + }); + }); + + describe('quit-time install', () => { + it('registers a close handler', async () => { + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.onCloseRequested).toHaveBeenCalledOnce(); + }); + + it('writes success marker before calling install', async () => { + const update = makeUpdate('0.5.0'); + mocks.check.mockResolvedValue(update); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + // Get the close handler + const closeHandler = mocks.onCloseRequested.mock.calls[0][0]; + const event = { preventDefault: vi.fn() }; + + // Track the order of operations + const order: string[] = []; + update.install.mockImplementation(async () => { + // At this point, localStorage should already be set + const marker = localStorage.getItem(STORAGE_KEY); + order.push(marker ? 'marker-set' : 'marker-missing'); + order.push('install'); + }); + + await closeHandler(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(order).toEqual(['marker-set', 'install']); + expect(mocks.windowClose).toHaveBeenCalled(); + }); + + it('writes failure marker when install throws', async () => { + const update = makeUpdate('0.5.0'); + update.install.mockRejectedValue(new Error('install failed')); + mocks.check.mockResolvedValue(update); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + const closeHandler = mocks.onCloseRequested.mock.calls[0][0]; + const event = { preventDefault: vi.fn() }; + + await closeHandler(event); + + const raw = localStorage.getItem(STORAGE_KEY); + const marker = JSON.parse(raw!); + expect(marker.failed).toBe(true); + expect(marker.version).toBe('0.5.0'); + expect(mocks.windowClose).toHaveBeenCalled(); + }); + + it('does not prevent close when no update is pending', async () => { + mocks.check.mockResolvedValue(null); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + const closeHandler = mocks.onCloseRequested.mock.calls[0][0]; + const event = { preventDefault: vi.fn() }; + + await closeHandler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('actions', () => { + it('openChangelog calls shell open', () => { + openChangelog(); + expect(mocks.shellOpen).toHaveBeenCalledWith('https://mouseterm.com/changelog'); + }); + }); +}); diff --git a/standalone/src/updater.ts b/standalone/src/updater.ts new file mode 100644 index 00000000..23e9c4ec --- /dev/null +++ b/standalone/src/updater.ts @@ -0,0 +1,152 @@ +import { useSyncExternalStore } from 'react'; +import { check, type Update } from '@tauri-apps/plugin-updater'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { getVersion } from '@tauri-apps/api/app'; +import { open } from '@tauri-apps/plugin-shell'; +import type { UpdateBannerState } from './UpdateBanner'; + +// --- State --- + +const STORAGE_KEY = 'mouseterm:update-result'; + +let state: UpdateBannerState = { status: 'idle' }; +let pendingUpdate: Update | null = null; +let currentVersion = ''; + +const listeners = new Set<() => void>(); + +function setState(next: UpdateBannerState) { + state = next; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): UpdateBannerState { + return state; +} + +export function useUpdateState(): UpdateBannerState { + return useSyncExternalStore(subscribe, getSnapshot); +} + +// --- Actions --- + +export function dismissBanner(): void { + setState({ status: 'dismissed' }); +} + +export function openChangelog(): void { + open('https://mouseterm.com/changelog').catch((e) => + console.error('[updater] Failed to open changelog:', e), + ); +} + +// --- Lifecycle --- + +export function startUpdateCheck(): void { + void runUpdateCheck(); +} + +async function runUpdateCheck(): Promise { + try { + currentVersion = await getVersion(); + } catch { + currentVersion = ''; + } + + // Check for post-install markers from a previous session + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + localStorage.removeItem(STORAGE_KEY); + const marker = JSON.parse(raw); + if (marker.failed) { + setState({ status: 'post-update-failure', version: marker.version }); + registerCloseHandler(); + return; + } else if (marker.from && marker.to) { + setState({ status: 'post-update-success', from: marker.from, to: marker.to }); + setTimeout(() => { + if (state.status === 'post-update-success') { + setState({ status: 'idle' }); + } + }, 10_000); + registerCloseHandler(); + return; + } + } + } catch { + // Corrupt marker — ignore + } + + // Wait 5 seconds, then check for updates + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + try { + const update = await check(); + if (!update) { + registerCloseHandler(); + return; + } + + await update.download(); + pendingUpdate = update; + setState({ status: 'downloaded', version: update.version }); + } catch (e) { + console.error('[updater] Check/download failed:', e); + } + + registerCloseHandler(); +} + +// --- Test support --- + +/** @internal Reset all module state for testing. */ +export function _resetForTesting(): void { + state = { status: 'idle' }; + pendingUpdate = null; + currentVersion = ''; + closeHandlerRegistered = false; + listeners.clear(); +} + +// --- Quit-time install --- + +let closeHandlerRegistered = false; + +function registerCloseHandler(): void { + if (closeHandlerRegistered) return; + closeHandlerRegistered = true; + + getCurrentWindow().onCloseRequested(async (event) => { + if (!pendingUpdate) return; + + event.preventDefault(); + + try { + // Write success marker BEFORE install — on Windows, NSIS force-kills the process + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + from: currentVersion, + to: pendingUpdate.version, + })); + await pendingUpdate.install(); + } catch (e) { + // Overwrite with failure marker + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + failed: true, + version: pendingUpdate!.version, + error: String(e), + })); + console.error('[updater] Install failed:', e); + } + + pendingUpdate = null; + await getCurrentWindow().close(); + }); +} diff --git a/standalone/vitest.config.ts b/standalone/vitest.config.ts new file mode 100644 index 00000000..eae1aa22 --- /dev/null +++ b/standalone/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'jsdom', + }, +}); From f4a31b6bdc87c34a681bf6e6ea4e7b785e5abcc2 Mon Sep 17 00:00:00 2001 From: nedtwigg Date: Fri, 10 Apr 2026 21:48:19 +0000 Subject: [PATCH 3/5] Claude Code review R1: fix post-install markers blocking update check, remove unused plugin-process dep, fix spec inconsistencies --- docs/specs/auto-update.md | 7 +++---- pnpm-lock.yaml | 10 ---------- standalone/package.json | 1 - standalone/src/updater.test.ts | 7 ++++--- standalone/src/updater.ts | 4 ---- 5 files changed, 7 insertions(+), 22 deletions(-) diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 70d1deaf..6755e79c 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -193,12 +193,11 @@ The `check()` call handles endpoint fetching, version comparison, and signature |------|----------------| | `standalone/src/updater.ts` | Update check, background download, quit-time install, post-install markers, state | | `standalone/src/UpdateBanner.tsx` | Banner React component | -| `standalone/src/App.tsx` | Mounts `` at the top of the app | +| `standalone/src/main.tsx` | Mounts `` at the top of the app | The updater module is standalone-only — it does not go into `lib/` because the VSCode extension and website have no use for it. ## Setup required -1. `pnpm --filter mouseterm-standalone add @tauri-apps/plugin-updater @tauri-apps/plugin-process` -2. Add `"process"` to the `plugins` allowlist in `tauri.conf.json` if not already present. -3. Add `"installMode": "passive"` to the updater Windows config in `tauri.conf.json`. +1. `pnpm --filter mouseterm-standalone add @tauri-apps/plugin-updater` +2. Add `"installMode": "passive"` to the updater Windows config in `tauri.conf.json`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f40b9cc0..889448a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: '@tauri-apps/api': specifier: ^2.0.0 version: 2.10.1 - '@tauri-apps/plugin-process': - specifier: ^2.3.1 - version: 2.3.1 '@tauri-apps/plugin-shell': specifier: ^2.0.0 version: 2.3.5 @@ -1436,9 +1433,6 @@ packages: engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-process@2.3.1': - resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} - '@tauri-apps/plugin-shell@2.3.5': resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} @@ -4422,10 +4416,6 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 - '@tauri-apps/plugin-process@2.3.1': - dependencies: - '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-shell@2.3.5': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/standalone/package.json b/standalone/package.json index b0e3b632..3c0b8a56 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -13,7 +13,6 @@ "dependencies": { "@phosphor-icons/react": "^2.1.10", "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.11.0", diff --git a/standalone/src/updater.test.ts b/standalone/src/updater.test.ts index 4e56498b..8c0d86f4 100644 --- a/standalone/src/updater.test.ts +++ b/standalone/src/updater.test.ts @@ -80,13 +80,14 @@ describe('updater', () => { expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); - it('skips update check when a post-install marker is present', async () => { + it('still runs update check after reading a post-install marker', async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ from: '0.3.0', to: '0.4.0' })); startUpdateCheck(); - await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); - expect(mocks.check).not.toHaveBeenCalled(); + expect(mocks.check).toHaveBeenCalledOnce(); }); }); diff --git a/standalone/src/updater.ts b/standalone/src/updater.ts index 23e9c4ec..3f2b4bac 100644 --- a/standalone/src/updater.ts +++ b/standalone/src/updater.ts @@ -68,8 +68,6 @@ async function runUpdateCheck(): Promise { const marker = JSON.parse(raw); if (marker.failed) { setState({ status: 'post-update-failure', version: marker.version }); - registerCloseHandler(); - return; } else if (marker.from && marker.to) { setState({ status: 'post-update-success', from: marker.from, to: marker.to }); setTimeout(() => { @@ -77,8 +75,6 @@ async function runUpdateCheck(): Promise { setState({ status: 'idle' }); } }, 10_000); - registerCloseHandler(); - return; } } } catch { From 41b25bad0d0fd3ca616000ee34347b7b30c58339 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 10 Apr 2026 15:51:25 -0700 Subject: [PATCH 4/5] Condense the spec. --- docs/specs/auto-update.md | 223 +++++++++++--------------------------- 1 file changed, 66 insertions(+), 157 deletions(-) diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 6755e79c..75cd6d61 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -1,203 +1,112 @@ # Auto-Update Spec -## Goal +The standalone app checks for updates on launch, downloads silently in the background, and installs when the user quits. A banner tells the user an update is pending. On next launch, a brief banner confirms the update succeeded (or notes a failure). -When a new version of the standalone app is available, download it silently in the background and install it when the app quits. Show a non-intrusive banner so the user knows an update is pending. Checking for updates is on by default. - -## Non-goals - -- No mid-session relaunches. The update installs at quit time — terminal sessions are never interrupted. -- No update checks in the VSCode extension (Marketplace handles that). -- No settings UI for update preferences in v1 — just the banner and a way to dismiss. - -## Infrastructure already in place - -The deploy pipeline (see `deploy.md`) already produces everything the Tauri updater needs: - -| Piece | Status | -|-------|--------| -| `tauri-plugin-updater` Rust crate | Registered in `lib.rs` | -| Updater endpoint (`mouseterm.com/standalone-latest.json`) | Configured in `tauri.conf.json` | -| Ed25519 public key | Configured in `tauri.conf.json` | -| Signed update bundles + `.sig` files | Generated by `sign-and-deploy.sh` | - -What's missing: the JS dependency (`@tauri-apps/plugin-updater`) and all frontend code. - -## Update check lifecycle +## How it works ``` app launch │ - ├─ wait 5 seconds (let the UI settle) + ├─ check for post-install markers in localStorage + │ ├─ success marker → show "Updated to vX.Y.Z" banner (auto-dismisses after 10s) + │ ├─ failure marker → show "Update failed — will retry" banner + │ └─ no marker → continue + │ + ├─ wait 5 seconds │ ├─ check(endpoint) ──→ no update ──→ done (silent) │ │ - │ └─→ update available - │ │ - │ └─→ download silently in background - │ │ - │ ├─→ download succeeds → show banner, hold Update object - │ │ - │ └─→ download fails → log error, done (silent) + │ └─→ update available → download in background + │ ├─ success → show "will install when you quit" banner + │ └─ failure → log error, done (silent) │ ... user works normally ... │ - user quits the app - │ - ├─ pending update? ──→ no ──→ exit normally - │ │ - │ └─→ yes ──→ install() - │ │ - │ ├─ success → save "updated-from" marker to localStorage, exit - │ │ │ - │ │ ├─ Windows: NSIS installer runs (force-quits app automatically) - │ │ └─ macOS/Linux: binary replaced in place, app exits normally - │ │ - │ └─ failure → save "update-failed" marker to localStorage, log error, exit normally + user quits │ - next launch - │ - ├─ "updated-from" marker? ──→ show "Updated to vX.Y.Z" banner (auto-dismisses after 10s), clear marker - │ - ├─ "update-failed" marker? ──→ show "Update failed — will retry next launch" banner, clear marker - │ - └─ neither ──→ normal launch (proceed to update check after 5s) + ├─ no pending update → exit normally + └─ pending update → write success marker → install() → exit + │ + └─ install fails → overwrite with failure marker → exit normally ``` -### Offline / network failure +The `Update` object from `download()` is held in memory for the session. The close handler intercepts the window close event, writes a success marker to `localStorage` *before* calling `install()` (because on Windows, NSIS force-kills the process), then calls `install()`. -If `check()` or `download()` fails (offline, timeout, DNS error), the app does nothing — no banner, no error, no retry. The next launch gets another chance. +## Banner states -### Post-install feedback +| State | Message | Changelog | Auto-dismiss | +|-------|---------|-----------|--------------| +| `downloaded` | "Update downloaded (v0.5.0) — will install when you quit." | Yes | No | +| `post-update-success` | "Updated to v0.5.0 — from v0.4.0." | Yes | 10 seconds | +| `post-update-failure` | "Update to v0.5.0 failed — will retry next launch." | No | No | -The update lifecycle doesn't end at `install()` — the user needs closure on next launch. +All states are dismissible via [×]. Dismissing hides the banner for the session only — it does not affect whether the update installs on quit. -**Success case:** Before exiting, write `{ "from": "0.4.0", "to": "0.5.0" }` to a `localStorage` key (`mouseterm:update-result`). On next launch, if this marker exists, show a banner: +The banner sits above the terminal content (pushes it down, never overlaps). It's 32px tall, uses `bg-surface-alt` / `text-muted` / `border-border` tokens for theme adaptation. -``` - Updated to v0.5.0 — from v0.4.0. [Changelog] [×] -``` - -This banner auto-dismisses after 10 seconds and is also dismissible via `[×]`. After showing (or auto-dismissing), clear the marker. The normal update check (5s delay) proceeds independently. - -**Failure case:** If `install()` throws, write `{ "failed": true, "version": "0.5.0", "error": "" }` to the same `localStorage` key. On next launch, show a persistent banner: - -``` - Update to v0.5.0 failed — will retry next launch. [×] -``` +## Platform behavior at quit -Clear the marker after showing. The next update check will re-download and try again on the following quit. No retry loop within the same session — one attempt per launch, same as the initial check. +| Platform | What `install()` does | App exit | +|----------|----------------------|----------| +| Windows | Launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | Automatic (NSIS) | +| macOS | Replaces the `.app` bundle in place | `getCurrentWindow().close()` after `install()` returns | +| Linux | Replaces the AppImage in place | `getCurrentWindow().close()` after `install()` returns | -**Why `localStorage`?** Tauri's webview `localStorage` persists across launches and doesn't require Rust-side state or filesystem writes. It also means the marker is scoped to the webview and cleaned up automatically if the user resets app data. +Windows uses `"installMode": "passive"` (configured in `tauri.conf.json` under `plugins.updater.windows`). -## Banner +## localStorage -After the update is downloaded, show a banner at the top of the window: +Single key: `mouseterm:update-result` -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ Update downloaded (v0.5.0) — will install when you quit. [Changelog] [×] │ -└──────────────────────────────────────────────────────────────────────────┘ -``` +| Scenario | Value written | When cleared | +|----------|--------------|--------------| +| Successful install | `{ "from": "0.4.0", "to": "0.5.0" }` | On next launch, after reading | +| Failed install | `{ "failed": true, "version": "0.5.0", "error": "..." }` | On next launch, after reading | -- The banner sits above the terminal content, not overlapping it — it pushes content down. -- `[Changelog]` — opens `https://mouseterm.com/changelog` in the default browser. -- `[×]` — dismisses the banner for **this session only**. The update still installs on quit. The banner does not reappear until next launch (where it won't appear at all, because the app is already updated). This means [×] is always a lightweight, low-stakes action — it hides a notification, it doesn't affect update behavior. +The success marker is written *before* `install()` because Windows NSIS force-kills the process — if we wrote it after, it would never persist. If `install()` then throws, the marker is overwritten with a failure entry. -### Why no "skip" or "install now" +## Files -- **No skip**: The update is already downloaded and will install on quit. There's nothing to skip — the user can't end up on a stale version unless they never quit the app. -- **No install now**: Installing requires closing all terminal sessions (on Windows, the NSIS installer force-quits the app). Offering an "install now" button invites accidental session loss. Quit-time install means the user has already decided to close their sessions. +| File | Role | +|------|------| +| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, background download, close handler, post-install markers | +| [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders banner based on `UpdateBannerState` | +| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Mounts `` above ``, calls `startUpdateCheck()` after platform init | -## Platform behavior at quit +All updater code is standalone-only — none of it lives in `lib/`. -| Platform | What `install()` does | App exit | -|----------|----------------------|----------| -| Windows | Launches NSIS installer, which force-kills the app | Automatic (NSIS handles it) | -| macOS | Replaces the `.app` bundle in place | App exits normally after `install()` returns | -| Linux | Replaces the AppImage in place | App exits normally after `install()` returns | - -On Windows, use the `on_before_exit` hook to perform any cleanup (e.g., shutting down the sidecar) before the NSIS installer kills the process: - -```rust -app.updater_builder() - .on_before_exit(|| { - // cleanup: shut down sidecar, save state, etc. - }) - .build()?; -``` +## Configuration -Configure the NSIS installer to run without user interaction: +In `standalone/src-tauri/tauri.conf.json`: ```json -{ - "plugins": { - "updater": { - "windows": { - "installMode": "passive" - } - } +"plugins": { + "updater": { + "pubkey": "", + "endpoints": ["https://mouseterm.com/standalone-latest.json"], + "windows": { "installMode": "passive" } } } ``` -`"passive"` shows a small progress bar but requires no clicks. This is the Tauri default and recommended mode. - -## Tauri API usage - -The JS side uses `@tauri-apps/plugin-updater` (must be added to `standalone/package.json`): +The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().build()` in `lib.rs`. No custom Rust commands or `on_before_exit` hooks — the JS close handler handles everything. -```ts -import { check } from '@tauri-apps/plugin-updater'; +## Dependencies -// On startup (after 5s delay) -const update = await check(); -if (update) { - await update.download((progress) => { - // progress.event: 'Started' | 'Progress' | 'Finished' - }); - // Store the update object — install() will be called at quit time -} -``` - -The `Update` object must be held in memory for the duration of the session. When the app is closing: - -```ts -// In the quit/close handler -if (pendingUpdate) { - try { - localStorage.setItem('mouseterm:update-result', JSON.stringify({ - from: currentVersion, - to: pendingUpdate.version, - })); - await pendingUpdate.install(); - // On macOS/Linux, this returns and the app exits normally. - // On Windows, the NSIS installer force-kills the process (on_before_exit fires first). - } catch (e) { - localStorage.setItem('mouseterm:update-result', JSON.stringify({ - failed: true, - version: pendingUpdate.version, - error: String(e), - })); - // Log and exit normally — will retry next launch. - console.error('Update install failed:', e); - } -} -``` +- `@tauri-apps/plugin-updater` — update check, download, install +- `@tauri-apps/api/window` — `getCurrentWindow()`, `onCloseRequested` +- `@tauri-apps/api/app` — `getVersion()` for the "from" version in markers +- `@tauri-apps/plugin-shell` — `open()` for the changelog link +- `tauri-plugin-updater` Rust crate — registered in `Cargo.toml` and `lib.rs` -The `check()` call handles endpoint fetching, version comparison, and signature verification using the pubkey in `tauri.conf.json`. No custom Rust commands needed. +## Design decisions -## Where code lives +**Why install on quit, not on demand?** MouseTerm is a terminal app with running processes. A mid-session relaunch would kill all sessions. By installing at quit time, the user has already decided to close their terminals. -| File | Responsibility | -|------|----------------| -| `standalone/src/updater.ts` | Update check, background download, quit-time install, post-install markers, state | -| `standalone/src/UpdateBanner.tsx` | Banner React component | -| `standalone/src/main.tsx` | Mounts `` at the top of the app | +**Why no "skip this version"?** The update is already downloaded and will install on quit regardless. There's nothing to opt out of. [×] just hides the notification. -The updater module is standalone-only — it does not go into `lib/` because the VSCode extension and website have no use for it. +**Why write the success marker before `install()`?** On Windows, the NSIS installer force-kills the process — code after `install()` may never run. Writing optimistically and overwriting on failure handles both platforms correctly. -## Setup required +**Why no `on_before_exit` Rust hook?** The JS close handler (`onCloseRequested`) runs before `install()` and handles marker writes. On Windows, NSIS handles process termination after `install()`. Sidecar cleanup is not currently handled at update-time — the sidecar process is orphaned and will exit when its stdin closes. -1. `pnpm --filter mouseterm-standalone add @tauri-apps/plugin-updater` -2. Add `"installMode": "passive"` to the updater Windows config in `tauri.conf.json`. +**Why `localStorage` instead of Tauri's store plugin?** `localStorage` persists across launches in Tauri's webview, requires no additional dependencies, and is automatically scoped to the app. If the user resets app data, markers are cleaned up naturally. From 1c7abe068ad5937e81e06c4ac34731b07788b092 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 10 Apr 2026 16:42:02 -0700 Subject: [PATCH 5/5] Fixup. --- docs/specs/auto-update.md | 20 ++++++++++++++------ lib/.storybook/preview.ts | 2 +- lib/src/App.tsx | 4 +++- lib/src/components/Baseboard.tsx | 7 +++++-- lib/src/components/Pond.tsx | 4 +++- standalone/src/UpdateBanner.tsx | 8 ++++---- standalone/src/main.tsx | 8 ++++++-- 7 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 75cd6d61..3d7ef94e 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -32,7 +32,9 @@ app launch The `Update` object from `download()` is held in memory for the session. The close handler intercepts the window close event, writes a success marker to `localStorage` *before* calling `install()` (because on Windows, NSIS force-kills the process), then calls `install()`. -## Banner states +## Update notice in the Baseboard + +Update status appears as a text notice on the right side of the Baseboard (the always-visible bottom strip — see `layout.md`). It coexists with doors and shortcut hints. | State | Message | Changelog | Auto-dismiss | |-------|---------|-----------|--------------| @@ -40,9 +42,13 @@ The `Update` object from `download()` is held in memory for the session. The clo | `post-update-success` | "Updated to v0.5.0 — from v0.4.0." | Yes | 10 seconds | | `post-update-failure` | "Update to v0.5.0 failed — will retry next launch." | No | No | -All states are dismissible via [×]. Dismissing hides the banner for the session only — it does not affect whether the update installs on quit. +All states are dismissible via [×]. Dismissing hides the notice for the session only — it does not affect whether the update installs on quit. + +The notice matches the Baseboard's existing text style (9px mono, `text-muted`). It's pushed right via `ml-auto` so it doesn't compete with doors or the shortcut hint on the left. -The banner sits above the terminal content (pushes it down, never overlaps). It's 32px tall, uses `bg-surface-alt` / `text-muted` / `border-border` tokens for theme adaptation. +### Threading + +The Baseboard is in `lib/` but the updater is standalone-only. The notice is threaded as a `ReactNode` prop: `App` → `Pond` → `Baseboard` (via `baseboardNotice`). This keeps all updater knowledge out of `lib/` — the Baseboard just renders an opaque slot. ## Platform behavior at quit @@ -70,10 +76,10 @@ The success marker is written *before* `install()` because Windows NSIS force-ki | File | Role | |------|------| | [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, background download, close handler, post-install markers | -| [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders banner based on `UpdateBannerState` | -| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Mounts `` above ``, calls `startUpdateCheck()` after platform init | +| [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders inline notice content for the Baseboard | +| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Passes `` as the `baseboardNotice` prop to ``, calls `startUpdateCheck()` after platform init | -All updater code is standalone-only — none of it lives in `lib/`. +All updater code is standalone-only. The Baseboard accepts a generic `notice` prop (`ReactNode`) — it has no knowledge of the updater. ## Configuration @@ -105,6 +111,8 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu **Why no "skip this version"?** The update is already downloaded and will install on quit regardless. There's nothing to opt out of. [×] just hides the notification. +**Why the Baseboard, not a top banner?** A top banner pushes terminal content down, which is disruptive in a terminal app. The Baseboard is already a status strip — the update notice fits naturally alongside doors and shortcut hints. It also avoids adding a new UI element; the notice just occupies unused space in an existing one. + **Why write the success marker before `install()`?** On Windows, the NSIS installer force-kills the process — code after `install()` may never run. Writing optimistically and overwriting on failure handles both platforms correctly. **Why no `on_before_exit` Rust hook?** The JS close handler (`onCloseRequested`) runs before `install()` and handles marker writes. On Windows, NSIS handles process termination after `install()`. Sidecar cleanup is not currently handled at update-time — the sidecar process is orphaned and will exit when its stdin closes. diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 95e102bd..57f87aeb 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -61,7 +61,7 @@ const preview: Preview = { } } // Force remount on theme change so terminals pick up new colors - return createElement('div', { key: themeName }, createElement(Story)); + return createElement('div', { key: themeName, style: { display: 'flex', flexDirection: 'column' as const, height: '100vh' } }, createElement(Story)); }, // FakePty: set scenario from parameters, clean up on unmount (Story, context) => { diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 6da74b79..4228dcf7 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -25,14 +25,16 @@ export default function App({ initialPaneIds, restoredLayout, initialDetached, + baseboardNotice, }: { initialPaneIds?: string[]; restoredLayout?: unknown; initialDetached?: PersistedDetachedItem[]; + baseboardNotice?: ReactNode; }) { return ( - + ); } diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index e6c24107..be78b467 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore } from 'react'; +import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react'; import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; import { Door } from './Door'; import { DoorElementsContext, type DetachedItem } from './Pond'; @@ -8,9 +8,10 @@ export interface BaseboardProps { items: DetachedItem[]; activeId: string | null; onReattach: (item: DetachedItem) => void; + notice?: ReactNode; } -export function Baseboard({ items, activeId, onReattach }: BaseboardProps) { +export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProps) { const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext); const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot); const containerRef = useRef(null); @@ -192,6 +193,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) { )} + + {notice &&
{notice}
}
); } diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 3f4d0ff5..87e57245 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -951,12 +951,14 @@ export function Pond({ initialDetached, onApiReady, onEvent, + baseboardNotice, }: { initialPaneIds?: string[]; restoredLayout?: unknown; initialDetached?: PersistedDetachedItem[]; onApiReady?: (api: DockviewApi) => void; onEvent?: (event: PondEvent) => void; + baseboardNotice?: React.ReactNode; } = {}) { const apiRef = useRef(null); const [dockviewApi, setDockviewApi] = useState(null); @@ -1786,7 +1788,7 @@ export function Pond({
{/* Baseboard — always visible */} - + {/* Kill confirmation overlay — centered over the pane being killed */} {confirmKill && ( diff --git a/standalone/src/UpdateBanner.tsx b/standalone/src/UpdateBanner.tsx index 44ef0c91..c618ef09 100644 --- a/standalone/src/UpdateBanner.tsx +++ b/standalone/src/UpdateBanner.tsx @@ -34,8 +34,8 @@ export function UpdateBanner({ state, onDismiss, onOpenChangelog }: UpdateBanner } return ( -
- {message} + + {message} {showChangelog && ( -
+ ); } diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 1fedda1a..fc4f9204 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -28,8 +28,12 @@ async function bootstrap() { createRoot(document.getElementById("root")!).render( - - + } + /> , ); }