Skip to content

Commit 0085c67

Browse files
authored
Require approval before downloading updates and use versioned changelog links (#48)
2 parents 4fef54b + 4a2b708 commit 0085c67

6 files changed

Lines changed: 184 additions & 54 deletions

File tree

docs/specs/auto-update.md

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Auto-Update Spec
22

3-
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).
3+
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).
44

55
## How it works
66

@@ -9,40 +9,49 @@ app launch
99
1010
├─ check for post-install markers in localStorage
1111
│ ├─ success marker → show "Updated to vX.Y.Z" banner (auto-dismisses after 10s)
12-
│ ├─ failure marker → show "Update failed — will retry" banner
12+
│ ├─ failure marker → show "Update failed." banner with debug action
1313
│ └─ no marker → continue
1414
1515
├─ wait 5 seconds
1616
1717
├─ check(endpoint) ──→ no update ──→ done (silent)
1818
│ │
19-
│ └─→ update available → download in background
20-
│ ├─ success → show "will install when you quit" banner
21-
│ └─ failure → log error, done (silent)
19+
│ └─→ update available → show approval prompt
20+
│ │
21+
│ ├─ dismissed/no approval → no download, no install
22+
│ │
23+
│ └─ user approves → download in background
24+
│ ├─ success → show "will install when you quit" banner
25+
│ └─ failure → log error, return to approval prompt
2226
2327
... user works normally ...
2428
2529
user quits
2630
27-
├─ no pending update → exit normally
28-
└─ pending update → write success marker → install() → exit
31+
├─ no approved, downloaded update → exit normally
32+
└─ approved, downloaded update → write success marker → install() → exit
2933
3034
└─ install fails → overwrite with failure marker → exit normally
3135
```
3236

33-
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.
37+
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.
3438

3539
## Update notice in the Baseboard
3640

3741
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.
3842

39-
| State | Message | Changelog | Auto-dismiss |
40-
|-------|---------|-----------|--------------|
41-
| `downloaded` | "Update downloaded (v0.5.0) — will install when you quit." | Yes | No |
42-
| `post-update-success` | "Updated to v0.5.0 — from v0.4.0." | Yes | 10 seconds |
43-
| `post-update-failure` | "Update to v0.5.0 failed — will retry next launch." | No | No |
43+
| State | Message | Actions | Auto-dismiss |
44+
|-------|---------|---------|--------------|
45+
| `available` | "Update available" | "Changelog", "Install when I quit" | No |
46+
| `downloading` | "Downloading update v0.5.0" | "Changelog" | No |
47+
| `downloaded` | "Update downloaded (v0.5.0) — will install when you quit" | "Changelog" | No |
48+
| `post-update-success` | "Updated to v0.5.0 — from v0.4.0" | "Changelog" | 10 seconds |
49+
| `post-update-failure` | "Update failed" | "Click here to debug" | No |
4450

45-
All states are dismissible via [×]. Dismissing hides the notice for the session only — it does not affect whether the update installs on quit.
51+
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>`.
52+
When a notice has follow-up actions, it uses ` · ` as the separator between the message and action labels.
53+
54+
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.
4655

4756
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.
4857

@@ -70,13 +79,13 @@ Single key: `mouseterm:update-result`
7079
| Successful install | `{ "from": "0.4.0", "to": "0.5.0" }` | On next launch, after reading |
7180
| Failed install | `{ "failed": true, "version": "0.5.0", "error": "..." }` | On next launch, after reading |
7281

73-
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.
82+
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.
7483

7584
## Files
7685

7786
| File | Role |
7887
|------|------|
79-
| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, background download, close handler, post-install markers |
88+
| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, user-approved download, close handler, post-install markers |
8089
| [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders inline notice content for the Baseboard |
8190
| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Passes `<ConnectedUpdateBanner />` as the `baseboardNotice` prop to `<App />`, calls `startUpdateCheck()` after platform init |
8291

@@ -108,9 +117,9 @@ The Rust side registers the plugin with `tauri_plugin_updater::Builder::new().bu
108117

109118
## Design decisions
110119

111-
**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.
120+
**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.
112121

113-
**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.
122+
**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.
114123

115124
**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.
116125

lib/src/stories/UpdateBanner.stories.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ function UpdateBannerStory({ state, expectedNullReason }: { state: UpdateBannerS
77
<UpdateBanner
88
state={state}
99
onDismiss={() => console.log('Dismiss')}
10+
onApproveUpdate={() => console.log('Install when I quit')}
1011
onOpenChangelog={() => console.log('Open changelog')}
1112
onOpenDebug={() => console.log('Open debug')}
1213
/>
@@ -27,6 +28,18 @@ const meta: Meta<typeof UpdateBannerStory> = {
2728
export default meta;
2829
type Story = StoryObj<typeof UpdateBannerStory>;
2930

31+
export const Available: Story = {
32+
args: {
33+
state: { status: 'available', version: '0.5.0' },
34+
},
35+
};
36+
37+
export const Downloading: Story = {
38+
args: {
39+
state: { status: 'downloading', version: '0.5.0' },
40+
},
41+
};
42+
3043
export const Downloaded: Story = {
3144
args: {
3245
state: { status: 'downloaded', version: '0.5.0' },
@@ -61,7 +74,7 @@ export const Dismissed: Story = {
6174

6275
export const NarrowViewport: Story = {
6376
args: {
64-
state: { status: 'downloaded', version: '0.5.0' },
77+
state: { status: 'available', version: '0.5.0' },
6578
},
6679
decorators: [
6780
(Story) => (

standalone/src/UpdateBanner.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import type { ReactNode } from 'react';
12
import { XIcon } from '@phosphor-icons/react';
23

34
export type UpdateBannerState =
45
| { status: 'idle' }
6+
| { status: 'available'; version: string }
7+
| { status: 'downloading'; version: string }
58
| { status: 'downloaded'; version: string }
69
| { status: 'dismissed' }
710
| { status: 'post-update-success'; from: string; to: string }
@@ -10,31 +13,52 @@ export type UpdateBannerState =
1013
interface UpdateBannerProps {
1114
state: UpdateBannerState;
1215
onDismiss: () => void;
16+
onApproveUpdate: () => void;
1317
onOpenChangelog: () => void;
1418
onOpenDebug: () => void;
1519
}
1620

1721
const linkClass = 'shrink-0 hover:underline';
1822
const linkStyle = { color: 'var(--vscode-textLink-foreground)' };
1923

20-
export function UpdateBanner({ state, onDismiss, onOpenChangelog, onOpenDebug }: UpdateBannerProps) {
24+
export function UpdateBanner({ state, onDismiss, onApproveUpdate, onOpenChangelog, onOpenDebug }: UpdateBannerProps) {
2125
if (state.status === 'idle' || state.status === 'dismissed') return null;
2226

23-
let message: string;
24-
let link: { label: string; onClick: () => void };
27+
let message: ReactNode;
28+
let links: { label: string; onClick: () => void }[];
2529

2630
switch (state.status) {
31+
case 'available':
32+
message = (
33+
<>
34+
Update available
35+
{' · '}
36+
<button onClick={onOpenChangelog} className={linkClass} style={linkStyle}>
37+
Changelog
38+
</button>
39+
{' · '}
40+
<button onClick={onApproveUpdate} className={linkClass} style={linkStyle}>
41+
Install when I quit
42+
</button>
43+
</>
44+
);
45+
links = [];
46+
break;
47+
case 'downloading':
48+
message = `Downloading update v${state.version}`;
49+
links = [{ label: 'Changelog', onClick: onOpenChangelog }];
50+
break;
2751
case 'downloaded':
28-
message = `Update downloaded (v${state.version}) — will install when you quit.`;
29-
link = { label: 'Changelog', onClick: onOpenChangelog };
52+
message = `Update downloaded (v${state.version}) — will install when you quit`;
53+
links = [{ label: 'Changelog', onClick: onOpenChangelog }];
3054
break;
3155
case 'post-update-success':
32-
message = `Updated to v${state.to} — from v${state.from}.`;
33-
link = { label: 'Changelog', onClick: onOpenChangelog };
56+
message = `Updated to v${state.to} — from v${state.from}`;
57+
links = [{ label: 'Changelog', onClick: onOpenChangelog }];
3458
break;
3559
case 'post-update-failure':
36-
message = 'Update failed.';
37-
link = { label: 'Click here to debug', onClick: onOpenDebug };
60+
message = 'Update failed';
61+
links = [{ label: 'Click here to debug', onClick: onOpenDebug }];
3862
break;
3963
default: {
4064
const _exhaustive: never = state;
@@ -45,9 +69,14 @@ export function UpdateBanner({ state, onDismiss, onOpenChangelog, onOpenDebug }:
4569
return (
4670
<span className="flex items-center gap-1.5 pb-1 text-sm font-mono text-muted">
4771
<span className="truncate">{message}</span>
48-
<button onClick={link.onClick} className={linkClass} style={linkStyle}>
49-
{link.label}
50-
</button>
72+
{links.map((link) => (
73+
<span key={link.label} className="contents">
74+
<span className="shrink-0">·</span>
75+
<button onClick={link.onClick} className={linkClass} style={linkStyle}>
76+
{link.label}
77+
</button>
78+
</span>
79+
))}
5180
<button
5281
onClick={onDismiss}
5382
className="shrink-0 rounded p-0.5 hover:bg-foreground/10 hover:text-foreground"

standalone/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
startUpdateCheck,
1616
useUpdateState,
1717
dismissBanner,
18+
approveUpdate,
1819
openChangelog,
1920
buildDebugReport,
2021
} from "./updater";
@@ -46,6 +47,7 @@ function ConnectedUpdateBanner() {
4647
<UpdateBanner
4748
state={state}
4849
onDismiss={dismissBanner}
50+
onApproveUpdate={approveUpdate}
4951
onOpenChangelog={openChangelog}
5052
onOpenDebug={() => {
5153
if (liveFailure) {

standalone/src/updater.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ function makeUpdate(version = '0.5.0') {
4747
}
4848

4949
// Import after mocks
50-
import { startUpdateCheck, openChangelog, buildDebugReport, _resetForTesting } from './updater';
50+
import {
51+
startUpdateCheck,
52+
approveUpdate,
53+
openChangelog,
54+
buildDebugReport,
55+
_resetForTesting,
56+
} from './updater';
5157

5258
describe('updater', () => {
5359
beforeEach(() => {
@@ -120,13 +126,17 @@ describe('updater', () => {
120126
expect(mocks.check).toHaveBeenCalledOnce();
121127
});
122128

123-
it('downloads when an update is available', async () => {
129+
it('does not download until the user approves the update', async () => {
124130
const update = makeUpdate();
125131
mocks.check.mockResolvedValue(update);
126132

127133
startUpdateCheck();
128134
await vi.advanceTimersByTimeAsync(5_000);
129-
// Let check() and download() resolve
135+
await vi.advanceTimersByTimeAsync(0);
136+
137+
expect(update.download).not.toHaveBeenCalled();
138+
139+
approveUpdate();
130140
await vi.advanceTimersByTimeAsync(0);
131141

132142
expect(update.download).toHaveBeenCalledOnce();
@@ -151,6 +161,8 @@ describe('updater', () => {
151161
startUpdateCheck();
152162
await vi.advanceTimersByTimeAsync(5_000);
153163
await vi.advanceTimersByTimeAsync(0);
164+
approveUpdate();
165+
await vi.advanceTimersByTimeAsync(0);
154166

155167
expect(update.download).toHaveBeenCalledOnce();
156168
});
@@ -172,6 +184,8 @@ describe('updater', () => {
172184
startUpdateCheck();
173185
await vi.advanceTimersByTimeAsync(5_000);
174186
await vi.advanceTimersByTimeAsync(0);
187+
approveUpdate();
188+
await vi.advanceTimersByTimeAsync(0);
175189

176190
// Get the close handler
177191
const closeHandler = mocks.onCloseRequested.mock.calls[0][0];
@@ -201,6 +215,8 @@ describe('updater', () => {
201215
startUpdateCheck();
202216
await vi.advanceTimersByTimeAsync(5_000);
203217
await vi.advanceTimersByTimeAsync(0);
218+
approveUpdate();
219+
await vi.advanceTimersByTimeAsync(0);
204220

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

233+
it('does not install an available update before approval', async () => {
234+
const update = makeUpdate('0.5.0');
235+
mocks.check.mockResolvedValue(update);
236+
237+
startUpdateCheck();
238+
await vi.advanceTimersByTimeAsync(5_000);
239+
await vi.advanceTimersByTimeAsync(0);
240+
241+
const closeHandler = mocks.onCloseRequested.mock.calls[0][0];
242+
const event = { preventDefault: vi.fn() };
243+
244+
await closeHandler(event);
245+
246+
expect(event.preventDefault).not.toHaveBeenCalled();
247+
expect(update.download).not.toHaveBeenCalled();
248+
expect(update.install).not.toHaveBeenCalled();
249+
});
250+
217251
it('does not prevent close when no update is pending', async () => {
218252
mocks.check.mockResolvedValue(null);
219253

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

233267
describe('actions', () => {
234-
it('openChangelog calls shell open', () => {
268+
it('openChangelog reads the current app version and opens release notes after it', async () => {
235269
openChangelog();
236-
expect(mocks.shellOpen).toHaveBeenCalledWith('https://mouseterm.com/changelog');
270+
await vi.advanceTimersByTimeAsync(0);
271+
272+
expect(mocks.shellOpen).toHaveBeenCalledWith('https://mouseterm.com/changelog/after/0.4.0');
237273
});
238274
});
239275

0 commit comments

Comments
 (0)