Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions docs/specs/auto-update.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -9,40 +9,49 @@ 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" | "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 |

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 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/<current-version>`.
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.

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.

Expand Down Expand Up @@ -70,13 +79,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 `<ConnectedUpdateBanner />` as the `baseboardNotice` prop to `<App />`, calls `startUpdateCheck()` after platform init |

Expand Down Expand Up @@ -108,9 +117,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.

Expand Down
15 changes: 14 additions & 1 deletion lib/src/stories/UpdateBanner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function UpdateBannerStory({ state, expectedNullReason }: { state: UpdateBannerS
<UpdateBanner
state={state}
onDismiss={() => console.log('Dismiss')}
onApproveUpdate={() => console.log('Install when I quit')}
onOpenChangelog={() => console.log('Open changelog')}
onOpenDebug={() => console.log('Open debug')}
/>
Expand All @@ -27,6 +28,18 @@ const meta: Meta<typeof UpdateBannerStory> = {
export default meta;
type Story = StoryObj<typeof UpdateBannerStory>;

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' },
Expand Down Expand Up @@ -61,7 +74,7 @@ export const Dismissed: Story = {

export const NarrowViewport: Story = {
args: {
state: { status: 'downloaded', version: '0.5.0' },
state: { status: 'available', version: '0.5.0' },
},
decorators: [
(Story) => (
Expand Down
53 changes: 41 additions & 12 deletions standalone/src/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { ReactNode } from 'react';
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 }
Expand All @@ -10,31 +13,52 @@ export type UpdateBannerState =
interface UpdateBannerProps {
state: UpdateBannerState;
onDismiss: () => void;
onApproveUpdate: () => void;
onOpenChangelog: () => void;
onOpenDebug: () => void;
}

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 message: ReactNode;
let links: { label: string; onClick: () => void }[];

switch (state.status) {
case 'available':
message = (
<>
Update available
{' · '}
<button onClick={onOpenChangelog} className={linkClass} style={linkStyle}>
Changelog
</button>
{' · '}
<button onClick={onApproveUpdate} className={linkClass} style={linkStyle}>
Install when I quit
</button>
</>
);
links = [];
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 };
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}.`;
link = { label: 'Changelog', onClick: onOpenChangelog };
message = `Updated to v${state.to} — from v${state.from}`;
links = [{ label: 'Changelog', onClick: onOpenChangelog }];
break;
case 'post-update-failure':
message = 'Update failed.';
link = { label: 'Click here to debug', onClick: onOpenDebug };
message = 'Update failed';
links = [{ label: 'Click here to debug', onClick: onOpenDebug }];
break;
default: {
const _exhaustive: never = state;
Expand All @@ -45,9 +69,14 @@ export function UpdateBanner({ state, onDismiss, onOpenChangelog, onOpenDebug }:
return (
<span className="flex items-center gap-1.5 pb-1 text-sm font-mono text-muted">
<span className="truncate">{message}</span>
<button onClick={link.onClick} className={linkClass} style={linkStyle}>
{link.label}
</button>
{links.map((link) => (
<span key={link.label} className="contents">
<span className="shrink-0">·</span>
<button onClick={link.onClick} className={linkClass} style={linkStyle}>
{link.label}
</button>
</span>
))}
<button
onClick={onDismiss}
className="shrink-0 rounded p-0.5 hover:bg-foreground/10 hover:text-foreground"
Expand Down
2 changes: 2 additions & 0 deletions standalone/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
startUpdateCheck,
useUpdateState,
dismissBanner,
approveUpdate,
openChangelog,
buildDebugReport,
} from "./updater";
Expand Down Expand Up @@ -46,6 +47,7 @@ function ConnectedUpdateBanner() {
<UpdateBanner
state={state}
onDismiss={dismissBanner}
onApproveUpdate={approveUpdate}
onOpenChangelog={openChangelog}
onOpenDebug={() => {
if (liveFailure) {
Expand Down
46 changes: 41 additions & 5 deletions standalone/src/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@ function makeUpdate(version = '0.5.0') {
}

// Import after mocks
import { startUpdateCheck, openChangelog, buildDebugReport, _resetForTesting } from './updater';
import {
startUpdateCheck,
approveUpdate,
openChangelog,
buildDebugReport,
_resetForTesting,
} from './updater';

describe('updater', () => {
beforeEach(() => {
Expand Down Expand Up @@ -120,13 +126,17 @@ describe('updater', () => {
expect(mocks.check).toHaveBeenCalledOnce();
});

it('downloads when an update is available', async () => {
it('does not download until the user approves the update', 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).not.toHaveBeenCalled();

approveUpdate();
await vi.advanceTimersByTimeAsync(0);

expect(update.download).toHaveBeenCalledOnce();
Expand All @@ -151,6 +161,8 @@ describe('updater', () => {
startUpdateCheck();
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(0);
approveUpdate();
await vi.advanceTimersByTimeAsync(0);

expect(update.download).toHaveBeenCalledOnce();
});
Expand All @@ -172,6 +184,8 @@ describe('updater', () => {
startUpdateCheck();
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(0);
approveUpdate();
await vi.advanceTimersByTimeAsync(0);

// Get the close handler
const closeHandler = mocks.onCloseRequested.mock.calls[0][0];
Expand Down Expand Up @@ -201,6 +215,8 @@ describe('updater', () => {
startUpdateCheck();
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(0);
approveUpdate();
await vi.advanceTimersByTimeAsync(0);

const closeHandler = mocks.onCloseRequested.mock.calls[0][0];
const event = { preventDefault: vi.fn() };
Expand All @@ -214,6 +230,24 @@ describe('updater', () => {
expect(mocks.windowClose).toHaveBeenCalled();
});

it('does not install an available update before approval', async () => {
const update = makeUpdate('0.5.0');
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);

expect(event.preventDefault).not.toHaveBeenCalled();
expect(update.download).not.toHaveBeenCalled();
expect(update.install).not.toHaveBeenCalled();
});

it('does not prevent close when no update is pending', async () => {
mocks.check.mockResolvedValue(null);

Expand All @@ -231,9 +265,11 @@ describe('updater', () => {
});

describe('actions', () => {
it('openChangelog calls shell open', () => {
it('openChangelog reads the current app version and opens release notes after it', async () => {
openChangelog();
expect(mocks.shellOpen).toHaveBeenCalledWith('https://mouseterm.com/changelog');
await vi.advanceTimersByTimeAsync(0);

expect(mocks.shellOpen).toHaveBeenCalledWith('https://mouseterm.com/changelog/after/0.4.0');
});
});

Expand Down
Loading
Loading