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
120 changes: 120 additions & 0 deletions docs/specs/auto-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# 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).

## How it works

```
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
│ └─ 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)
... user works normally ...
user quits
├─ no pending update → exit normally
└─ pending 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()`.

## 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 |

All states are dismissible via [×]. Dismissing hides the notice for the session only — it does not affect whether the update installs on quit.

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.

### Threading

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.

## Platform behavior at quit

| Platform | What `install()` does | App exit |
|----------|----------------------|----------|
| Windows | Launches NSIS installer in passive mode (progress bar, no user interaction). Force-kills the app. | Automatic (NSIS) |
| macOS | Replaces the `.app` bundle in place | `getCurrentWindow().close()` after `install()` returns |
| Linux | Replaces the AppImage in place | `getCurrentWindow().close()` after `install()` returns |

Windows uses `"installMode": "passive"` (configured in `tauri.conf.json` under `plugins.updater.windows`).

## localStorage

Single key: `mouseterm:update-result`

| Scenario | Value written | When cleared |
|----------|--------------|--------------|
| 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.

## Files

| File | Role |
|------|------|
| [`standalone/src/updater.ts`](../../standalone/src/updater.ts) | State machine, update check, background 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 |

All updater code is standalone-only. The Baseboard accepts a generic `notice` prop (`ReactNode`) — it has no knowledge of the updater.

## Configuration

In `standalone/src-tauri/tauri.conf.json`:

```json
"plugins": {
"updater": {
"pubkey": "<ed25519 public key>",
"endpoints": ["https://mouseterm.com/standalone-latest.json"],
"windows": { "installMode": "passive" }
}
}
```

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.

## Dependencies

- `@tauri-apps/plugin-updater` — update check, download, install
- `@tauri-apps/api/window` — `getCurrentWindow()`, `onCloseRequested`
- `@tauri-apps/api/app` — `getVersion()` for the "from" version in markers
- `@tauri-apps/plugin-shell` — `open()` for the changelog link
- `tauri-plugin-updater` Rust crate — registered in `Cargo.toml` and `lib.rs`

## 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 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 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.

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

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

**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.
2 changes: 1 addition & 1 deletion lib/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const preview: Preview = {
}
}
// Force remount on theme change so terminals pick up new colors
return createElement('div', { key: themeName }, createElement(Story));
return createElement('div', { key: themeName, style: { display: 'flex', flexDirection: 'column' as const, height: '100vh' } }, createElement(Story));
},
// FakePty: set scenario from parameters, clean up on unmount
(Story, context) => {
Expand Down
4 changes: 3 additions & 1 deletion lib/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ export default function App({
initialPaneIds,
restoredLayout,
initialDetached,
baseboardNotice,
}: {
initialPaneIds?: string[];
restoredLayout?: unknown;
initialDetached?: PersistedDetachedItem[];
baseboardNotice?: ReactNode;
}) {
return (
<ErrorBoundary>
<Pond initialPaneIds={initialPaneIds} restoredLayout={restoredLayout} initialDetached={initialDetached} />
<Pond initialPaneIds={initialPaneIds} restoredLayout={restoredLayout} initialDetached={initialDetached} baseboardNotice={baseboardNotice} />
</ErrorBoundary>
);
}
7 changes: 5 additions & 2 deletions lib/src/components/Baseboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore } from 'react';
import { useRef, useState, useMemo, useLayoutEffect, useContext, useSyncExternalStore, type ReactNode } from 'react';
import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react';
import { Door } from './Door';
import { DoorElementsContext, type DetachedItem } from './Pond';
Expand All @@ -8,9 +8,10 @@ export interface BaseboardProps {
items: DetachedItem[];
activeId: string | null;
onReattach: (item: DetachedItem) => void;
notice?: ReactNode;
}

export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
export function Baseboard({ items, activeId, onReattach, notice }: BaseboardProps) {
const { elements: doorElements, bumpVersion } = useContext(DoorElementsContext);
const sessionStates = useSyncExternalStore(subscribeToSessionStateChanges, getSessionStateSnapshot);
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -192,6 +193,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) {
<CaretRightIcon size={10} weight="bold" />
</button>
)}

{notice && <div className="ml-auto shrink-0">{notice}</div>}
</div>
);
}
6 changes: 4 additions & 2 deletions lib/src/components/Pond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -951,12 +951,14 @@ export function Pond({
initialDetached,
onApiReady,
onEvent,
baseboardNotice,
}: {
initialPaneIds?: string[];
restoredLayout?: unknown;
initialDetached?: PersistedDetachedItem[];
onApiReady?: (api: DockviewApi) => void;
onEvent?: (event: PondEvent) => void;
baseboardNotice?: React.ReactNode;
} = {}) {
const apiRef = useRef<DockviewApi | null>(null);
const [dockviewApi, setDockviewApi] = useState<DockviewApi | null>(null);
Expand Down Expand Up @@ -1770,7 +1772,7 @@ export function Pond({
<DoorElementsContext.Provider value={{ elements: doorElements, version: doorElementsVersion, bumpVersion: bumpDoorElementsVersion }}>
<RenamingIdContext.Provider value={renamingPaneId}>
<ZoomedContext.Provider value={zoomed}>
<div className="h-screen flex flex-col bg-surface text-foreground font-sans overflow-hidden">
<div className="flex-1 min-h-0 flex flex-col bg-surface text-foreground font-sans overflow-hidden">
{/* Dockview */}
<div className="flex-1 min-h-0 relative p-1.5">
<div ref={dockviewContainerRef} className="absolute inset-1.5">
Expand All @@ -1786,7 +1788,7 @@ export function Pond({
</div>

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

{/* Kill confirmation overlay — centered over the pane being killed */}
{confirmKill && (
Expand Down
6 changes: 6 additions & 0 deletions lib/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ body {
font-family: var(--mt-font-family);
}

#root {
display: flex;
flex-direction: column;
height: 100vh;
}

/* --- Dockview overrides: flatten tab bar into a pane header --- */

/* Each group has exactly one panel (tab stacking is disabled),
Expand Down
71 changes: 71 additions & 0 deletions lib/src/stories/UpdateBanner.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react';
import { UpdateBanner, type UpdateBannerState } from '../../../standalone/src/UpdateBanner';

function UpdateBannerStory({ state }: { state: UpdateBannerState }) {
return (
<div className="bg-surface" style={{ width: '100%' }}>
<UpdateBanner
state={state}
onDismiss={() => console.log('Dismiss')}
onOpenChangelog={() => console.log('Open changelog')}
/>
</div>
);
}

const meta: Meta<typeof UpdateBannerStory> = {
title: 'Components/UpdateBanner',
component: UpdateBannerStory,
};

export default meta;
type Story = StoryObj<typeof UpdateBannerStory>;

export const Downloaded: Story = {
args: {
state: { status: 'downloaded', version: '0.5.0' },
},
};

export const PostUpdateSuccess: Story = {
args: {
state: { status: 'post-update-success', from: '0.4.0', to: '0.5.0' },
},
};

export const PostUpdateFailure: Story = {
args: {
state: { status: 'post-update-failure', version: '0.5.0' },
},
};

export const Idle: Story = {
args: {
state: { status: 'idle' },
},
};

export const Dismissed: Story = {
args: {
state: { status: 'dismissed' },
},
};

export const LongVersionString: Story = {
args: {
state: { status: 'downloaded', version: '1.23.456-beta.7+build.2025.04.10' },
},
};

export const NarrowViewport: Story = {
args: {
state: { status: 'downloaded', version: '0.5.0' },
},
decorators: [
(Story) => (
<div style={{ width: 400 }}>
<Story />
</div>
),
],
};
Loading
Loading