From b7cfef2b195e243a5c42ce83e0d4d5d297157345 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 5 May 2026 23:57:52 -0700 Subject: [PATCH 1/6] Require approval before downloading updates --- docs/specs/auto-update.md | 44 ++++++++++++--------- lib/src/stories/UpdateBanner.stories.tsx | 13 ++++++ standalone/src/UpdateBanner.tsx | 32 +++++++++++---- standalone/src/main.tsx | 2 + standalone/src/updater.test.ts | 40 +++++++++++++++++-- standalone/src/updater.ts | 50 ++++++++++++++++++++---- 6 files changed, 145 insertions(+), 36 deletions(-) diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index 46a3446..bb907c9 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -1,6 +1,6 @@ # Auto-Update Spec -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). +The standalone app checks for updates on launch and prompts in the Baseboard when one is available. It does not download or install the update until the user approves the prompt. Once approved, the app downloads the update in the background and installs it when the user quits. On next launch, a brief banner confirms the update succeeded (or notes a failure). ## How it works @@ -9,40 +9,48 @@ app launch │ ├─ 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 + │ ├─ failure marker → show "Update failed." banner with debug action │ └─ no marker → continue │ ├─ wait 5 seconds │ ├─ check(endpoint) ──→ no update ──→ done (silent) │ │ - │ └─→ update available → download in background - │ ├─ success → show "will install when you quit" banner - │ └─ failure → log error, done (silent) + │ └─→ update available → show approval prompt + │ │ + │ ├─ dismissed/no approval → no download, no install + │ │ + │ └─ user approves → download in background + │ ├─ success → show "will install when you quit" banner + │ └─ failure → log error, return to approval prompt │ ... user works normally ... │ user quits │ - ├─ no pending update → exit normally - └─ pending update → write success marker → install() → exit + ├─ no approved, downloaded update → exit normally + └─ approved, downloaded update → write success marker → install() → exit │ └─ install fails → overwrite with failure marker → exit normally ``` -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()`. In Vite dev mode (`pnpm dev:standalone`), the close handler skips `install()` without preventing the close. Dev mode is useful for testing check/download/banner behavior, but install must be tested from a packaged app because the updater resolves its replacement target from the current executable path. +The `Update` object returned by `check()` is held in memory as an available update. Clicking the approval action calls `download()` and promotes it to a pending update only after the download succeeds. The close handler intercepts the window close event only when there is an approved, downloaded update, writes a success marker to `localStorage` *before* calling `install()` (because on Windows, NSIS force-kills the process), then calls `install()`. In Vite dev mode (`pnpm dev:standalone`), the close handler skips `install()` without preventing the close. Dev mode is useful for testing check/download/banner behavior, but install must be tested from a packaged app because the updater resolves its replacement target from the current executable path. ## 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 | -|-------|---------|-----------|--------------| -| `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 | +| State | Message | Actions | Auto-dismiss | +|-------|---------|---------|--------------| +| `available` | "Update available (v0.5.0)." | "Install on quit", "Changelog" | No | +| `downloading` | "Downloading update (v0.5.0)..." | "Changelog" | No | +| `downloaded` | "Update downloaded (v0.5.0) — will install when you quit." | "Changelog" | No | +| `post-update-success` | "Updated to v0.5.0 — from v0.4.0." | "Changelog" | 10 seconds | +| `post-update-failure` | "Update failed." | "Click here to debug" | No | -All states are dismissible via [×]. Dismissing hides the notice for the session only — it does not affect whether the update installs on quit. +The "Install on quit" action is the user's approval to download the update now and install it when they quit. + +All states are dismissible via [×]. Dismissing an unapproved `available` notice means no update is downloaded or installed in that session. Dismissing a `downloading` or `downloaded` notice hides it for the session only — it does not cancel an already-approved download/install. 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. @@ -70,13 +78,13 @@ Single key: `mouseterm:update-result` | 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 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. +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. No marker is written for an update that was found but never approved. ## Files | File | Role | |------|------| -| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, background download, close handler, post-install markers | +| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, user-approved download, close handler, post-install markers | | [`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 | @@ -108,9 +116,9 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu ## Design decisions -**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. +**Why install on quit after approval, not immediately?** 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. -**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 no silent download?** Update bundles can be large, can fail for environment-specific reasons, and may surprise users who did not opt into changing the app. The launch probe is silent, but download/install only begins after explicit approval. **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. diff --git a/lib/src/stories/UpdateBanner.stories.tsx b/lib/src/stories/UpdateBanner.stories.tsx index f42ace8..9a79b75 100644 --- a/lib/src/stories/UpdateBanner.stories.tsx +++ b/lib/src/stories/UpdateBanner.stories.tsx @@ -7,6 +7,7 @@ function UpdateBannerStory({ state, expectedNullReason }: { state: UpdateBannerS console.log('Dismiss')} + onApproveUpdate={() => console.log('Approve update')} onOpenChangelog={() => console.log('Open changelog')} onOpenDebug={() => console.log('Open debug')} /> @@ -27,6 +28,18 @@ const meta: Meta = { export default meta; type Story = StoryObj; +export const Available: Story = { + args: { + state: { status: 'available', version: '0.5.0' }, + }, +}; + +export const Downloading: Story = { + args: { + state: { status: 'downloading', version: '0.5.0' }, + }, +}; + export const Downloaded: Story = { args: { state: { status: 'downloaded', version: '0.5.0' }, diff --git a/standalone/src/UpdateBanner.tsx b/standalone/src/UpdateBanner.tsx index 23c1ab8..15f95e6 100644 --- a/standalone/src/UpdateBanner.tsx +++ b/standalone/src/UpdateBanner.tsx @@ -2,6 +2,8 @@ import { XIcon } from '@phosphor-icons/react'; export type UpdateBannerState = | { status: 'idle' } + | { status: 'available'; version: string } + | { status: 'downloading'; version: string } | { status: 'downloaded'; version: string } | { status: 'dismissed' } | { status: 'post-update-success'; from: string; to: string } @@ -10,6 +12,7 @@ export type UpdateBannerState = interface UpdateBannerProps { state: UpdateBannerState; onDismiss: () => void; + onApproveUpdate: () => void; onOpenChangelog: () => void; onOpenDebug: () => void; } @@ -17,24 +20,35 @@ interface UpdateBannerProps { const linkClass = 'shrink-0 hover:underline'; const linkStyle = { color: 'var(--vscode-textLink-foreground)' }; -export function UpdateBanner({ state, onDismiss, onOpenChangelog, onOpenDebug }: UpdateBannerProps) { +export function UpdateBanner({ state, onDismiss, onApproveUpdate, onOpenChangelog, onOpenDebug }: UpdateBannerProps) { if (state.status === 'idle' || state.status === 'dismissed') return null; let message: string; - let link: { label: string; onClick: () => void }; + let links: { label: string; onClick: () => void }[]; switch (state.status) { + case 'available': + message = `Update available (v${state.version}).`; + links = [ + { label: 'Install on quit', onClick: onApproveUpdate }, + { label: 'Changelog', onClick: onOpenChangelog }, + ]; + break; + case 'downloading': + message = `Downloading update (v${state.version})...`; + links = [{ label: 'Changelog', onClick: onOpenChangelog }]; + break; case 'downloaded': message = `Update downloaded (v${state.version}) — will install when you quit.`; - link = { label: 'Changelog', onClick: onOpenChangelog }; + links = [{ label: 'Changelog', onClick: onOpenChangelog }]; break; case 'post-update-success': message = `Updated to v${state.to} — from v${state.from}.`; - link = { label: 'Changelog', onClick: onOpenChangelog }; + links = [{ label: 'Changelog', onClick: onOpenChangelog }]; break; case 'post-update-failure': message = 'Update failed.'; - link = { label: 'Click here to debug', onClick: onOpenDebug }; + links = [{ label: 'Click here to debug', onClick: onOpenDebug }]; break; default: { const _exhaustive: never = state; @@ -45,9 +59,11 @@ export function UpdateBanner({ state, onDismiss, onOpenChangelog, onOpenDebug }: return ( {message} - + {links.map((link) => ( + + ))} + {' · '} + + + ); + links = []; break; case 'downloading': message = `Downloading update (v${state.version})...`; From 4a2b708cc8f24f14afe7c615c4ee1483ad33329a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 6 May 2026 01:51:34 -0700 Subject: [PATCH 6/6] Use dot separators in update banner --- docs/specs/auto-update.md | 9 +++++---- standalone/src/UpdateBanner.tsx | 17 ++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index eb0285d..bb347a5 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -43,12 +43,13 @@ Update status appears as a text notice on the right side of the Baseboard (the a | State | Message | Actions | Auto-dismiss | |-------|---------|---------|--------------| | `available` | "Update available" | "Changelog", "Install when I quit" | No | -| `downloading` | "Downloading update (v0.5.0)..." | "Changelog" | No | -| `downloaded` | "Update downloaded (v0.5.0) — will install when you quit." | "Changelog" | No | -| `post-update-success` | "Updated to v0.5.0 — from v0.4.0." | "Changelog" | 10 seconds | -| `post-update-failure` | "Update failed." | "Click here to debug" | No | +| `downloading` | "Downloading update v0.5.0" | "Changelog" | No | +| `downloaded` | "Update downloaded (v0.5.0) — will install when you quit" | "Changelog" | No | +| `post-update-success` | "Updated to v0.5.0 — from v0.4.0" | "Changelog" | 10 seconds | +| `post-update-failure` | "Update failed" | "Click here to debug" | No | The "Install when I quit" action is the user's approval to download the update now and install it when they quit. The inline "Changelog" action calls Tauri's `getVersion()` and opens `https://mouseterm.com/changelog/after/`. +When a notice has follow-up actions, it uses ` · ` as the separator between the message and action labels. All states are dismissible via [×]. Dismissing an unapproved `available` notice means no update is downloaded or installed in that session. Dismissing a `downloading` or `downloaded` notice hides it for the session only — it does not cancel an already-approved download/install. diff --git a/standalone/src/UpdateBanner.tsx b/standalone/src/UpdateBanner.tsx index 81da077..b26c4de 100644 --- a/standalone/src/UpdateBanner.tsx +++ b/standalone/src/UpdateBanner.tsx @@ -45,19 +45,19 @@ export function UpdateBanner({ state, onDismiss, onApproveUpdate, onOpenChangelo links = []; break; case 'downloading': - message = `Downloading update (v${state.version})...`; + message = `Downloading update v${state.version}`; links = [{ label: 'Changelog', onClick: onOpenChangelog }]; break; case 'downloaded': - message = `Update downloaded (v${state.version}) — will install when you quit.`; + message = `Update downloaded (v${state.version}) — will install when you quit`; links = [{ label: 'Changelog', onClick: onOpenChangelog }]; break; case 'post-update-success': - message = `Updated to v${state.to} — from v${state.from}.`; + message = `Updated to v${state.to} — from v${state.from}`; links = [{ label: 'Changelog', onClick: onOpenChangelog }]; break; case 'post-update-failure': - message = 'Update failed.'; + message = 'Update failed'; links = [{ label: 'Click here to debug', onClick: onOpenDebug }]; break; default: { @@ -70,9 +70,12 @@ export function UpdateBanner({ state, onDismiss, onApproveUpdate, onOpenChangelo {message} {links.map((link) => ( - + + · + + ))}