|
| 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. |
0 commit comments