Skip to content

Commit f6e0c83

Browse files
authored
Add auto-update for the standalone app (#7)
2 parents 82e8249 + 1c7abe0 commit f6e0c83

File tree

16 files changed

+932
-14
lines changed

16 files changed

+932
-14
lines changed

docs/specs/auto-update.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Auto-Update Spec
2+
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).
4+
5+
## How it works
6+
7+
```
8+
app launch
9+
10+
├─ check for post-install markers in localStorage
11+
│ ├─ success marker → show "Updated to vX.Y.Z" banner (auto-dismisses after 10s)
12+
│ ├─ failure marker → show "Update failed — will retry" banner
13+
│ └─ no marker → continue
14+
15+
├─ wait 5 seconds
16+
17+
├─ check(endpoint) ──→ no update ──→ done (silent)
18+
│ │
19+
│ └─→ update available → download in background
20+
│ ├─ success → show "will install when you quit" banner
21+
│ └─ failure → log error, done (silent)
22+
23+
... user works normally ...
24+
25+
user quits
26+
27+
├─ no pending update → exit normally
28+
└─ pending update → write success marker → install() → exit
29+
30+
└─ install fails → overwrite with failure marker → exit normally
31+
```
32+
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()`.
34+
35+
## Update notice in the Baseboard
36+
37+
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.
38+
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 |
44+
45+
All states are dismissible via [×]. Dismissing hides the notice for the session only — it does not affect whether the update installs on quit.
46+
47+
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.
48+
49+
### Threading
50+
51+
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.
52+
53+
## Platform behavior at quit
54+
55+
| Platform | What `install()` does | App exit |
56+
|----------|----------------------|----------|
57+
| Windows | Launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | Automatic (NSIS) |
58+
| macOS | Replaces the `.app` bundle in place | `getCurrentWindow().close()` after `install()` returns |
59+
| Linux | Replaces the AppImage in place | `getCurrentWindow().close()` after `install()` returns |
60+
61+
Windows uses `"installMode": "passive"` (configured in `tauri.conf.json` under `plugins.updater.windows`).
62+
63+
## localStorage
64+
65+
Single key: `mouseterm:update-result`
66+
67+
| Scenario | Value written | When cleared |
68+
|----------|--------------|--------------|
69+
| Successful install | `{ "from": "0.4.0", "to": "0.5.0" }` | On next launch, after reading |
70+
| Failed install | `{ "failed": true, "version": "0.5.0", "error": "..." }` | On next launch, after reading |
71+
72+
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.
73+
74+
## Files
75+
76+
| File | Role |
77+
|------|------|
78+
| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, background download, close handler, post-install markers |
79+
| [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Pure presentational component — renders inline notice content for the Baseboard |
80+
| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | Passes `<ConnectedUpdateBanner />` as the `baseboardNotice` prop to `<App />`, calls `startUpdateCheck()` after platform init |
81+
82+
All updater code is standalone-only. The Baseboard accepts a generic `notice` prop (`ReactNode`) — it has no knowledge of the updater.
83+
84+
## Configuration
85+
86+
In `standalone/src-tauri/tauri.conf.json`:
87+
88+
```json
89+
"plugins": {
90+
"updater": {
91+
"pubkey": "<ed25519 public key>",
92+
"endpoints": ["https://mouseterm.com/standalone-latest.json"],
93+
"windows": { "installMode": "passive" }
94+
}
95+
}
96+
```
97+
98+
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.
99+
100+
## Dependencies
101+
102+
- `@tauri-apps/plugin-updater` — update check, download, install
103+
- `@tauri-apps/api/window``getCurrentWindow()`, `onCloseRequested`
104+
- `@tauri-apps/api/app``getVersion()` for the "from" version in markers
105+
- `@tauri-apps/plugin-shell``open()` for the changelog link
106+
- `tauri-plugin-updater` Rust crate — registered in `Cargo.toml` and `lib.rs`
107+
108+
## Design decisions
109+
110+
**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.
111+
112+
**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.
113+
114+
**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.
115+
116+
**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.
117+
118+
**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.
119+
120+
**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.

lib/.storybook/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const preview: Preview = {
6161
}
6262
}
6363
// Force remount on theme change so terminals pick up new colors
64-
return createElement('div', { key: themeName }, createElement(Story));
64+
return createElement('div', { key: themeName, style: { display: 'flex', flexDirection: 'column' as const, height: '100vh' } }, createElement(Story));
6565
},
6666
// FakePty: set scenario from parameters, clean up on unmount
6767
(Story, context) => {

lib/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ export default function App({
2525
initialPaneIds,
2626
restoredLayout,
2727
initialDetached,
28+
baseboardNotice,
2829
}: {
2930
initialPaneIds?: string[];
3031
restoredLayout?: unknown;
3132
initialDetached?: PersistedDetachedItem[];
33+
baseboardNotice?: ReactNode;
3234
}) {
3335
return (
3436
<ErrorBoundary>
35-
<Pond initialPaneIds={initialPaneIds} restoredLayout={restoredLayout} initialDetached={initialDetached} />
37+
<Pond initialPaneIds={initialPaneIds} restoredLayout={restoredLayout} initialDetached={initialDetached} baseboardNotice={baseboardNotice} />
3638
</ErrorBoundary>
3739
);
3840
}

lib/src/components/Baseboard.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore } from 'react';
1+
import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react';
22
import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react';
33
import { Door } from './Door';
44
import { DoorElementsContext, type DetachedItem } from './Pond';
@@ -8,9 +8,10 @@ export interface BaseboardProps {
88
items: DetachedItem[];
99
activeId: string | null;
1010
onReattach: (item: DetachedItem) => void;
11+
notice?: ReactNode;
1112
}
1213

13-
export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
14+
export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProps) {
1415
const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext);
1516
const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot);
1617
const containerRef = useRef<HTMLDivElement>(null);
@@ -192,6 +193,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
192193
<CaretRightIcon size={10} weight="bold" />
193194
</button>
194195
)}
196+
197+
{notice && <div className="ml-auto shrink-0">{notice}</div>}
195198
</div>
196199
);
197200
}

lib/src/components/Pond.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -951,12 +951,14 @@ export function Pond({
951951
initialDetached,
952952
onApiReady,
953953
onEvent,
954+
baseboardNotice,
954955
}: {
955956
initialPaneIds?: string[];
956957
restoredLayout?: unknown;
957958
initialDetached?: PersistedDetachedItem[];
958959
onApiReady?: (api: DockviewApi) => void;
959960
onEvent?: (event: PondEvent) => void;
961+
baseboardNotice?: React.ReactNode;
960962
} = {}) {
961963
const apiRef = useRef<DockviewApi | null>(null);
962964
const [dockviewApi, setDockviewApi] = useState<DockviewApi | null>(null);
@@ -1770,7 +1772,7 @@ export function Pond({
17701772
<DoorElementsContext.Provider value={{ elements: doorElements, version: doorElementsVersion, bumpVersion: bumpDoorElementsVersion }}>
17711773
<RenamingIdContext.Provider value={renamingPaneId}>
17721774
<ZoomedContext.Provider value={zoomed}>
1773-
<div className="h-screen flex flex-col bg-surface text-foreground font-sans overflow-hidden">
1775+
<div className="flex-1 min-h-0 flex flex-col bg-surface text-foreground font-sans overflow-hidden">
17741776
{/* Dockview */}
17751777
<div className="flex-1 min-h-0 relative p-1.5">
17761778
<div ref={dockviewContainerRef} className="absolute inset-1.5">
@@ -1786,7 +1788,7 @@ export function Pond({
17861788
</div>
17871789

17881790
{/* Baseboard — always visible */}
1789-
<Baseboard items={detached} activeId={selectedType === 'door' ? selectedId : null} onReattach={handleReattach} />
1791+
<Baseboard items={detached} activeId={selectedType === 'door' ? selectedId : null} onReattach={handleReattach} notice={baseboardNotice} />
17901792

17911793
{/* Kill confirmation overlay — centered over the pane being killed */}
17921794
{confirmKill && (

lib/src/index.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ body {
1010
font-family: var(--mt-font-family);
1111
}
1212

13+
#root {
14+
display: flex;
15+
flex-direction: column;
16+
height: 100vh;
17+
}
18+
1319
/* --- Dockview overrides: flatten tab bar into a pane header --- */
1420

1521
/* Each group has exactly one panel (tab stacking is disabled),
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { UpdateBanner, type UpdateBannerState } from '../../../standalone/src/UpdateBanner';
3+
4+
function UpdateBannerStory({ state }: { state: UpdateBannerState }) {
5+
return (
6+
<div className="bg-surface" style={{ width: '100%' }}>
7+
<UpdateBanner
8+
state={state}
9+
onDismiss={() => console.log('Dismiss')}
10+
onOpenChangelog={() => console.log('Open changelog')}
11+
/>
12+
</div>
13+
);
14+
}
15+
16+
const meta: Meta<typeof UpdateBannerStory> = {
17+
title: 'Components/UpdateBanner',
18+
component: UpdateBannerStory,
19+
};
20+
21+
export default meta;
22+
type Story = StoryObj<typeof UpdateBannerStory>;
23+
24+
export const Downloaded: Story = {
25+
args: {
26+
state: { status: 'downloaded', version: '0.5.0' },
27+
},
28+
};
29+
30+
export const PostUpdateSuccess: Story = {
31+
args: {
32+
state: { status: 'post-update-success', from: '0.4.0', to: '0.5.0' },
33+
},
34+
};
35+
36+
export const PostUpdateFailure: Story = {
37+
args: {
38+
state: { status: 'post-update-failure', version: '0.5.0' },
39+
},
40+
};
41+
42+
export const Idle: Story = {
43+
args: {
44+
state: { status: 'idle' },
45+
},
46+
};
47+
48+
export const Dismissed: Story = {
49+
args: {
50+
state: { status: 'dismissed' },
51+
},
52+
};
53+
54+
export const LongVersionString: Story = {
55+
args: {
56+
state: { status: 'downloaded', version: '1.23.456-beta.7+build.2025.04.10' },
57+
},
58+
};
59+
60+
export const NarrowViewport: Story = {
61+
args: {
62+
state: { status: 'downloaded', version: '0.5.0' },
63+
},
64+
decorators: [
65+
(Story) => (
66+
<div style={{ width: 400 }}>
67+
<Story />
68+
</div>
69+
),
70+
],
71+
};

0 commit comments

Comments
 (0)