diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md new file mode 100644 index 0000000..3d7ef94 --- /dev/null +++ b/docs/specs/auto-update.md @@ -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 `` as the `baseboardNotice` prop to ``, 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": "", + "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. diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 95e102b..57f87ae 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -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) => { diff --git a/lib/src/App.tsx b/lib/src/App.tsx index 6da74b7..4228dcf 100644 --- a/lib/src/App.tsx +++ b/lib/src/App.tsx @@ -25,14 +25,16 @@ export default function App({ initialPaneIds, restoredLayout, initialDetached, + baseboardNotice, }: { initialPaneIds?: string[]; restoredLayout?: unknown; initialDetached?: PersistedDetachedItem[]; + baseboardNotice?: ReactNode; }) { return ( - + ); } diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index e6c2410..be78b46 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -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'; @@ -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(null); @@ -192,6 +193,8 @@ export function Baseboard({ items, activeId, onReattach }: BaseboardProps) { )} + + {notice &&
{notice}
} ); } diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 7f4fc47..87e5724 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -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(null); const [dockviewApi, setDockviewApi] = useState(null); @@ -1770,7 +1772,7 @@ export function Pond({ -
+
{/* Dockview */}
@@ -1786,7 +1788,7 @@ export function Pond({
{/* Baseboard — always visible */} - + {/* Kill confirmation overlay — centered over the pane being killed */} {confirmKill && ( diff --git a/lib/src/index.css b/lib/src/index.css index 48dce91..dd0a8ba 100644 --- a/lib/src/index.css +++ b/lib/src/index.css @@ -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), diff --git a/lib/src/stories/UpdateBanner.stories.tsx b/lib/src/stories/UpdateBanner.stories.tsx new file mode 100644 index 0000000..86e2b3e --- /dev/null +++ b/lib/src/stories/UpdateBanner.stories.tsx @@ -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 ( +
+ console.log('Dismiss')} + onOpenChangelog={() => console.log('Open changelog')} + /> +
+ ); +} + +const meta: Meta = { + title: 'Components/UpdateBanner', + component: UpdateBannerStory, +}; + +export default meta; +type Story = StoryObj; + +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) => ( +
+ +
+ ), + ], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6613a99..889448a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ importers: version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vitest: specifier: ^4.1.0 - version: 4.1.1(jsdom@24.1.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) standalone: dependencies: @@ -86,6 +86,9 @@ importers: '@tauri-apps/plugin-shell': specifier: ^2.0.0 version: 2.3.5 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -123,6 +126,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.2.0 version: 4.3.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + jsdom: + specifier: ^29.0.2 + version: 29.0.2 tailwindcss: specifier: ^4.0.0 version: 4.2.2 @@ -132,6 +138,9 @@ importers: vite: specifier: ^7.3.0 version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.1 + version: 4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) standalone/sidecar: dependencies: @@ -221,6 +230,17 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asamuzakjp/css-color@5.1.9': + resolution: {integrity: sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.9': + resolution: {integrity: sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -342,10 +362,18 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} engines: {node: '>=18'} @@ -353,6 +381,13 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-color-parser@3.1.0': resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} engines: {node: '>=18'} @@ -360,16 +395,41 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms@3.0.5': resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} peerDependencies: '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -691,6 +751,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@isaacs/cliui@9.0.0': resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} @@ -1367,6 +1436,9 @@ packages: '@tauri-apps/plugin-shell@2.3.5': resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1631,6 +1703,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binaryextensions@6.11.0: resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} engines: {node: '>=4'} @@ -1773,6 +1848,10 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} @@ -1791,6 +1870,10 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2127,6 +2210,10 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html5parser@2.0.2: resolution: {integrity: sha512-L0y+IdTVxHsovmye8MBtFgBvWZnq1C9WnI/SmJszxoQjmUH1psX2uzDk21O5k5et6udxdGjwxkbmT9eVRoG05w==} @@ -2245,6 +2332,15 @@ packages: canvas: optional: true + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2424,6 +2520,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -2575,6 +2674,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3009,6 +3111,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -3021,10 +3130,18 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -3229,6 +3346,10 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3241,10 +3362,18 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3333,6 +3462,22 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@asamuzakjp/css-color@5.1.9': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.0.9': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -3524,13 +3669,24 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@csstools/color-helpers@5.1.0': {} + '@csstools/color-helpers@6.0.2': {} + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/color-helpers': 5.1.0 @@ -3538,12 +3694,29 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -3716,6 +3889,8 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true + '@exodus/bytes@1.15.0': {} + '@isaacs/cliui@9.0.0': {} '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': @@ -4245,6 +4420,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -4568,6 +4747,10 @@ snapshots: baseline-browser-mapping@2.10.10: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binaryextensions@6.11.0: dependencies: editions: 6.22.0 @@ -4721,6 +4904,11 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} css.escape@1.5.1: {} @@ -4737,6 +4925,13 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5103,6 +5298,12 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html5parser@2.0.2: dependencies: tslib: 2.8.1 @@ -5233,6 +5434,32 @@ snapshots: - supports-color - utf-8-validate + jsdom@29.0.2: + dependencies: + '@asamuzakjp/css-color': 5.1.9 + '@asamuzakjp/dom-selector': 7.0.9 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.5 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-schema-traverse@1.0.0: {} @@ -5387,6 +5614,8 @@ snapshots: math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} merge2@1.4.1: {} @@ -5534,6 +5763,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -6020,6 +6253,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + tmp@0.2.5: {} to-regex-range@5.0.1: @@ -6033,10 +6272,18 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + tr46@5.1.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-dedent@2.2.0: {} tsconfig-paths@4.2.0: @@ -6147,7 +6394,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 - vitest@4.1.1(jsdom@24.1.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): + vitest@4.1.1(jsdom@29.0.2)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): dependencies: '@vitest/expect': 4.1.1 '@vitest/mocker': 4.1.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) @@ -6170,7 +6417,7 @@ snapshots: vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: - jsdom: 24.1.3 + jsdom: 29.0.2 transitivePeerDependencies: - msw @@ -6180,6 +6427,8 @@ snapshots: webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -6188,11 +6437,21 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/standalone/package.json b/standalone/package.json index ec0dcf6..3c0b8a5 100644 --- a/standalone/package.json +++ b/standalone/package.json @@ -7,12 +7,14 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "tauri": "tauri" + "tauri": "tauri", + "test": "vitest run" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.10.1", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "dockview-react": "^5.1.0", @@ -27,8 +29,10 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react-swc": "^4.2.0", + "jsdom": "^29.0.2", "tailwindcss": "^4.0.0", "typescript": "^5.9.0", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.1.1" } } diff --git a/standalone/src-tauri/capabilities/default.json b/standalone/src-tauri/capabilities/default.json index 116f694..b718307 100644 --- a/standalone/src-tauri/capabilities/default.json +++ b/standalone/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "core:default", "shell:allow-spawn", "shell:allow-stdin-write", - "shell:allow-kill" + "shell:allow-kill", + "shell:allow-open" ] } diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 0972db1..aafff23 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -47,7 +47,10 @@ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNUE3RThENTQxQTY0REIKUldUYlpCcFVqWDVhckxRQjBFbGw4anhJMUZ5L2VEU0pGNTluS1hPR0F1OGc1T3BUYTVjbHd0WG0K", "endpoints": [ "https://mouseterm.com/standalone-latest.json" - ] + ], + "windows": { + "installMode": "passive" + } } } } diff --git a/standalone/src/UpdateBanner.tsx b/standalone/src/UpdateBanner.tsx new file mode 100644 index 0000000..c618ef0 --- /dev/null +++ b/standalone/src/UpdateBanner.tsx @@ -0,0 +1,56 @@ +import { XIcon } from '@phosphor-icons/react'; + +export type UpdateBannerState = + | { status: 'idle' } + | { status: 'downloaded'; version: string } + | { status: 'dismissed' } + | { status: 'post-update-success'; from: string; to: string } + | { status: 'post-update-failure'; version: string }; + +interface UpdateBannerProps { + state: UpdateBannerState; + onDismiss: () => void; + onOpenChangelog: () => void; +} + +export function UpdateBanner({ state, onDismiss, onOpenChangelog }: UpdateBannerProps) { + if (state.status === 'idle' || state.status === 'dismissed') return null; + + let message: string; + let showChangelog = false; + + switch (state.status) { + case 'downloaded': + message = `Update downloaded (v${state.version}) \u2014 will install when you quit.`; + showChangelog = true; + break; + case 'post-update-success': + message = `Updated to v${state.to} \u2014 from v${state.from}.`; + showChangelog = true; + break; + case 'post-update-failure': + message = `Update to v${state.version} failed \u2014 will retry next launch.`; + break; + } + + return ( + + {message} + {showChangelog && ( + + )} + + + ); +} diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index e270cf4..fc4f920 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -5,20 +5,35 @@ import { reconnectFromInit } from "mouseterm-lib/lib/reconnect"; import App from "mouseterm-lib/App"; import "mouseterm-lib/index.css"; import { TauriAdapter } from "./tauri-adapter"; +import { UpdateBanner } from "./UpdateBanner"; +import { startUpdateCheck, useUpdateState, dismissBanner, openChangelog } from "./updater"; // Initialize Tauri platform adapter before rendering const platform = new TauriAdapter(); setPlatform(platform); +function ConnectedUpdateBanner() { + const state = useUpdateState(); + return ; +} + // Await init() first to register event listeners before reconnecting async function bootstrap() { await platform.init(); const { initAlarmStateReceiver } = await import("mouseterm-lib/lib/terminal-registry"); initAlarmStateReceiver(); const result = await reconnectFromInit(platform); + + startUpdateCheck(); + createRoot(document.getElementById("root")!).render( - + } + /> , ); } diff --git a/standalone/src/updater.test.ts b/standalone/src/updater.test.ts new file mode 100644 index 0000000..8c0d86f --- /dev/null +++ b/standalone/src/updater.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks --- + +const mocks = vi.hoisted(() => ({ + check: vi.fn(), + getVersion: vi.fn(), + onCloseRequested: vi.fn(), + windowClose: vi.fn(), + shellOpen: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: mocks.check, +})); + +vi.mock('@tauri-apps/api/app', () => ({ + getVersion: mocks.getVersion, +})); + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + onCloseRequested: mocks.onCloseRequested, + close: mocks.windowClose, + }), +})); + +vi.mock('@tauri-apps/plugin-shell', () => ({ + open: mocks.shellOpen, +})); + +// --- Helpers --- + +const STORAGE_KEY = 'mouseterm:update-result'; + +function makeUpdate(version = '0.5.0') { + return { + version, + download: vi.fn(async () => {}), + install: vi.fn(async () => {}), + }; +} + +// Import after mocks +import { startUpdateCheck, openChangelog, _resetForTesting } from './updater'; + +describe('updater', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + localStorage.clear(); + _resetForTesting(); + mocks.getVersion.mockResolvedValue('0.4.0'); + mocks.check.mockResolvedValue(null); + mocks.onCloseRequested.mockResolvedValue(vi.fn()); + mocks.windowClose.mockResolvedValue(undefined); + mocks.shellOpen.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('post-install markers', () => { + it('reads a success marker and clears it from localStorage', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ from: '0.3.0', to: '0.4.0' })); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(0); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('reads a failure marker and clears it from localStorage', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ failed: true, version: '0.5.0', error: 'oops' })); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(0); + + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('still runs update check after reading a post-install marker', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ from: '0.3.0', to: '0.4.0' })); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.check).toHaveBeenCalledOnce(); + }); + }); + + describe('update check', () => { + it('waits 5 seconds before checking', async () => { + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(4_999); + expect(mocks.check).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(mocks.check).toHaveBeenCalledOnce(); + }); + + it('downloads when an update is available', 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).toHaveBeenCalledOnce(); + }); + + it('does not crash on check failure', async () => { + mocks.check.mockRejectedValue(new Error('network')); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + // No throw, no crash + expect(mocks.check).toHaveBeenCalledOnce(); + }); + + it('does not crash on download failure', async () => { + const update = makeUpdate(); + update.download.mockRejectedValue(new Error('disk full')); + mocks.check.mockResolvedValue(update); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + expect(update.download).toHaveBeenCalledOnce(); + }); + }); + + describe('quit-time install', () => { + it('registers a close handler', async () => { + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.onCloseRequested).toHaveBeenCalledOnce(); + }); + + it('writes success marker before calling install', async () => { + const update = makeUpdate('0.5.0'); + mocks.check.mockResolvedValue(update); + + startUpdateCheck(); + await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(0); + + // Get the close handler + const closeHandler = mocks.onCloseRequested.mock.calls[0][0]; + const event = { preventDefault: vi.fn() }; + + // Track the order of operations + const order: string[] = []; + update.install.mockImplementation(async () => { + // At this point, localStorage should already be set + const marker = localStorage.getItem(STORAGE_KEY); + order.push(marker ? 'marker-set' : 'marker-missing'); + order.push('install'); + }); + + await closeHandler(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(order).toEqual(['marker-set', 'install']); + expect(mocks.windowClose).toHaveBeenCalled(); + }); + + it('writes failure marker when install throws', async () => { + const update = makeUpdate('0.5.0'); + update.install.mockRejectedValue(new Error('install failed')); + 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); + + const raw = localStorage.getItem(STORAGE_KEY); + const marker = JSON.parse(raw!); + expect(marker.failed).toBe(true); + expect(marker.version).toBe('0.5.0'); + expect(mocks.windowClose).toHaveBeenCalled(); + }); + + it('does not prevent close when no update is pending', async () => { + mocks.check.mockResolvedValue(null); + + 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(); + }); + }); + + describe('actions', () => { + it('openChangelog calls shell open', () => { + openChangelog(); + expect(mocks.shellOpen).toHaveBeenCalledWith('https://mouseterm.com/changelog'); + }); + }); +}); diff --git a/standalone/src/updater.ts b/standalone/src/updater.ts new file mode 100644 index 0000000..3f2b4ba --- /dev/null +++ b/standalone/src/updater.ts @@ -0,0 +1,148 @@ +import { useSyncExternalStore } from 'react'; +import { check, type Update } from '@tauri-apps/plugin-updater'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { getVersion } from '@tauri-apps/api/app'; +import { open } from '@tauri-apps/plugin-shell'; +import type { UpdateBannerState } from './UpdateBanner'; + +// --- State --- + +const STORAGE_KEY = 'mouseterm:update-result'; + +let state: UpdateBannerState = { status: 'idle' }; +let pendingUpdate: Update | null = null; +let currentVersion = ''; + +const listeners = new Set<() => void>(); + +function setState(next: UpdateBannerState) { + state = next; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): UpdateBannerState { + return state; +} + +export function useUpdateState(): UpdateBannerState { + return useSyncExternalStore(subscribe, getSnapshot); +} + +// --- Actions --- + +export function dismissBanner(): void { + setState({ status: 'dismissed' }); +} + +export function openChangelog(): void { + open('https://mouseterm.com/changelog').catch((e) => + console.error('[updater] Failed to open changelog:', e), + ); +} + +// --- Lifecycle --- + +export function startUpdateCheck(): void { + void runUpdateCheck(); +} + +async function runUpdateCheck(): Promise { + try { + currentVersion = await getVersion(); + } catch { + currentVersion = ''; + } + + // Check for post-install markers from a previous session + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + localStorage.removeItem(STORAGE_KEY); + const marker = JSON.parse(raw); + if (marker.failed) { + setState({ status: 'post-update-failure', version: marker.version }); + } else if (marker.from && marker.to) { + setState({ status: 'post-update-success', from: marker.from, to: marker.to }); + setTimeout(() => { + if (state.status === 'post-update-success') { + setState({ status: 'idle' }); + } + }, 10_000); + } + } + } catch { + // Corrupt marker — ignore + } + + // Wait 5 seconds, then check for updates + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + try { + const update = await check(); + if (!update) { + registerCloseHandler(); + return; + } + + await update.download(); + pendingUpdate = update; + setState({ status: 'downloaded', version: update.version }); + } catch (e) { + console.error('[updater] Check/download failed:', e); + } + + registerCloseHandler(); +} + +// --- Test support --- + +/** @internal Reset all module state for testing. */ +export function _resetForTesting(): void { + state = { status: 'idle' }; + pendingUpdate = null; + currentVersion = ''; + closeHandlerRegistered = false; + listeners.clear(); +} + +// --- Quit-time install --- + +let closeHandlerRegistered = false; + +function registerCloseHandler(): void { + if (closeHandlerRegistered) return; + closeHandlerRegistered = true; + + getCurrentWindow().onCloseRequested(async (event) => { + if (!pendingUpdate) return; + + event.preventDefault(); + + try { + // Write success marker BEFORE install — on Windows, NSIS force-kills the process + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + from: currentVersion, + to: pendingUpdate.version, + })); + await pendingUpdate.install(); + } catch (e) { + // Overwrite with failure marker + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + failed: true, + version: pendingUpdate!.version, + error: String(e), + })); + console.error('[updater] Install failed:', e); + } + + pendingUpdate = null; + await getCurrentWindow().close(); + }); +} diff --git a/standalone/vitest.config.ts b/standalone/vitest.config.ts new file mode 100644 index 0000000..eae1aa2 --- /dev/null +++ b/standalone/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'jsdom', + }, +});