Skip to content

Commit 69a453d

Browse files
alichherawallaUserclaude
authored
fix(update): drive install with quitAndInstall + "Restart to update" banner (#8)
* fix(update): drive install with quitAndInstall + "Restart to update" banner A downloaded update only applied on a graceful quit (autoInstallOnAppQuit); force-killing the app (Activity Monitor, kill -9, dev restart) skipped the Squirrel swap, so a fully-staged update could sit unapplied indefinitely (seen: a complete 0.0.25 download while the app stayed on 0.0.24). - main: register `update:install` IPC -> autoUpdater.quitAndInstall() - preload: expose onUpdateDownloaded() + installUpdate() - renderer: "Off Grid AI <v> is ready / Restart to update" banner that drives the install directly instead of relying on the user knowing to Cmd+Q - regression guard asserting the IPC + preload contract Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(update): address review — staged-version seed, failure handling, typed bridge From CodeRabbit + Qodo review of PR #8: - reliability: persist staged version in main + update:staged-version getter, seeded on renderer mount — on macOS the app keeps running with zero windows, so a download that finished before a window existed would never show the banner (the update:downloaded event only reaches windows open at the time) - stability: await installUpdate() and reset the CTA + notify on failure, so a rejected invoke can't leave the button stuck on "Restarting…" - types: drop `any` from the onUpdateDownloaded IPC payload - copy: banner says "Update <v> is ready" (no product-name string to drift) - test: guard the staged-version persistence + getter contract Skipped (with reason): the phosphor-icon finding — App.tsx uses @tabler/icons throughout (incl. the adjacent recording banner's IconLoader2); introducing phosphor for two icons would fork the icon set in one file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: User <user@Users-MacBook-Pro-174.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a57be99 commit 69a453d

5 files changed

Lines changed: 131 additions & 1 deletion

File tree

src/main/__tests__/updater.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Regression guard for the "downloaded update never installs" bug. Squirrel.Mac
3+
* only swaps the app bundle on a GRACEFUL quit (autoInstallOnAppQuit); a
4+
* force-kill (Activity Monitor, kill -9, the dev-restart path) skips it, so a
5+
* fully-downloaded update can sit staged forever (observed: a complete 0.0.25
6+
* download that never applied while the app stayed on 0.0.24).
7+
*
8+
* The fix exposes an explicit install path: main registers an `update:install`
9+
* IPC that calls autoUpdater.quitAndInstall(), and the renderer surfaces a
10+
* "Restart to update" button driven by it. updater.ts imports electron, so it
11+
* can't be unit-run — assert the contract by reading the source (same approach
12+
* as extract-prompt.test.ts).
13+
*/
14+
import { describe, it, expect } from 'vitest';
15+
import fs from 'fs';
16+
import path from 'path';
17+
18+
const UPDATER = path.resolve(process.cwd(), 'src/main/updater.ts');
19+
const PRELOAD = path.resolve(process.cwd(), 'src/preload/index.ts');
20+
const updaterSrc = fs.readFileSync(UPDATER, 'utf-8');
21+
const preloadSrc = fs.readFileSync(PRELOAD, 'utf-8');
22+
23+
describe('auto-update: explicit install path', () => {
24+
it('registers an update:install IPC handler', () => {
25+
expect(updaterSrc).toMatch(/ipcMain\.handle\(\s*['"]update:install['"]/);
26+
});
27+
28+
it('drives the install via quitAndInstall (not a passive wait for quit)', () => {
29+
expect(updaterSrc).toMatch(/autoUpdater\.quitAndInstall\(\)/);
30+
});
31+
32+
it('still emits update:downloaded so the renderer can prompt', () => {
33+
expect(updaterSrc).toMatch(/send\(\s*['"]update:downloaded['"]/);
34+
});
35+
36+
it('persists the staged version + exposes a getter (macOS zero-window case)', () => {
37+
// The event only reaches windows open at download time; a window created
38+
// later must still be able to ask whether an update is already staged.
39+
expect(updaterSrc).toMatch(/stagedVersion\s*=\s*i\.version/);
40+
expect(updaterSrc).toMatch(/ipcMain\.handle\(\s*['"]update:staged-version['"]/);
41+
expect(preloadSrc).toMatch(/getStagedUpdateVersion:\s*\(\)\s*=>\s*ipcRenderer\.invoke\(\s*['"]update:staged-version['"]/);
42+
});
43+
44+
it('preload bridges installUpdate() to the update:install channel', () => {
45+
expect(preloadSrc).toMatch(/installUpdate:\s*\(\)\s*=>\s*ipcRenderer\.invoke\(\s*['"]update:install['"]/);
46+
});
47+
48+
it('preload subscribes the renderer to update:downloaded', () => {
49+
expect(preloadSrc).toMatch(/onUpdateDownloaded/);
50+
expect(preloadSrc).toMatch(/ipcRenderer\.on\(\s*['"]update:downloaded['"]/);
51+
});
52+
});

src/main/updater.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,38 @@
22
// few hours; downloads in the background and installs on quit. A native
33
// notification fires when an update is downloaded (checkForUpdatesAndNotify).
44
import { autoUpdater } from 'electron-updater';
5-
import { BrowserWindow } from 'electron';
5+
import { BrowserWindow, ipcMain } from 'electron';
6+
7+
// Version of an update that finished downloading and is staged for install
8+
// (null = none). Held in main so a window created AFTER the download finished
9+
// (on macOS the app keeps running with zero windows) can still seed the banner
10+
// via update:staged-version — the update:downloaded event alone only reaches
11+
// windows that existed at download time.
12+
let stagedVersion: string | null = null;
613

714
export function startAutoUpdates(): void {
815
autoUpdater.autoDownload = true;
916
autoUpdater.autoInstallOnAppQuit = true;
1017

18+
// Apply a staged update on demand. autoInstallOnAppQuit only swaps the bundle
19+
// on a GRACEFUL quit — a force-kill (Activity Monitor, kill -9, killall) skips
20+
// it, so a fully-downloaded update can sit unapplied forever. quitAndInstall
21+
// forces the clean quit + relaunch + swap, so the renderer's "Restart to
22+
// update" button always lands the update regardless of how the app is closed.
23+
ipcMain.handle('update:install', () => {
24+
autoUpdater.quitAndInstall();
25+
});
26+
27+
// Lets a freshly-created window ask whether an update is already staged.
28+
ipcMain.handle('update:staged-version', () => stagedVersion);
29+
1130
autoUpdater.on('error', (e) => console.error('[update] error', e));
1231
autoUpdater.on('checking-for-update', () => console.log('[update] checking…'));
1332
autoUpdater.on('update-available', (i) => console.log('[update] available', i.version));
1433
autoUpdater.on('update-not-available', () => console.log('[update] up to date'));
1534
autoUpdater.on('update-downloaded', (i) => {
1635
console.log('[update] downloaded', i.version, '— will install on quit');
36+
stagedVersion = i.version;
1737
BrowserWindow.getAllWindows().forEach((w) => w.webContents.send('update:downloaded', { version: i.version }));
1838
});
1939

src/preload/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ try {
122122
return () => ipcRenderer.removeListener('reprocess:progress', subscription)
123123
},
124124

125+
// Auto-update — fired when a new version finished downloading and is staged.
126+
// installUpdate() forces the quit+swap (Squirrel only applies on a graceful
127+
// quit; a force-kill would otherwise leave the download unapplied).
128+
onUpdateDownloaded: (callback: (data: { version: string }) => void) => {
129+
const subscription = (_event: unknown, data: { version: string }) => callback(data)
130+
ipcRenderer.on('update:downloaded', subscription)
131+
return () => ipcRenderer.removeListener('update:downloaded', subscription)
132+
},
133+
getStagedUpdateVersion: () => ipcRenderer.invoke('update:staged-version'),
134+
installUpdate: () => ipcRenderer.invoke('update:install'),
135+
125136
// Watcher Events
126137
onWatcherData: (callback: (data: any) => void) => {
127138
const subscription = (_: any, data: any) => callback(data)

src/renderer/src/App.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,11 @@ function AppContent() {
161161
const [viewMode, setViewMode] = useState<ViewMode>(isPro ? 'day' : 'models');
162162
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
163163
const [selectedMemoryId, setSelectedMemoryId] = useState<number | null>(null);
164+
// Version of a downloaded-and-staged update (null = none). Surfaced as a banner
165+
// with a "Restart to update" button — Squirrel only applies on a clean quit, so
166+
// we drive the install explicitly instead of waiting for one.
167+
const [updateReady, setUpdateReady] = useState<string | null>(null);
168+
const [installing, setInstalling] = useState(false);
164169
const [selectedEntityId, setSelectedEntityId] = useState<number | null>(null);
165170
const [searchQuery, setSearchQuery] = useState('');
166171
// Search filter + sort live here (not in the screen) so they survive navigating
@@ -373,6 +378,18 @@ function AppContent() {
373378
unsubscribers.push(unsubscribe);
374379
}
375380

381+
// A new version finished downloading and is staged — show the restart banner.
382+
// Seed from main too: on macOS the app can keep running with no windows, so a
383+
// download that finished before this window existed would otherwise be missed
384+
// (the event only reaches windows open at download time).
385+
if (window.api?.onUpdateDownloaded) {
386+
window.api.getStagedUpdateVersion?.().then((v) => { if (v) setUpdateReady(v); }).catch(() => {});
387+
const unsubscribe = window.api.onUpdateDownloaded((data) => {
388+
setUpdateReady(data.version);
389+
});
390+
unsubscribers.push(unsubscribe);
391+
}
392+
376393
return () => {
377394
unsubscribers.forEach(unsub => unsub());
378395
};
@@ -563,6 +580,33 @@ function AppContent() {
563580
)}
564581
</button>
565582
)}
583+
{/* Update ready — a new version downloaded and is staged. The button drives
584+
the install (quit + swap + relaunch); a plain quit/force-kill would leave
585+
it unapplied. */}
586+
{updateReady && (
587+
<div className="absolute right-4 top-4 z-50 flex items-center gap-3 rounded-md border border-green-500/40 bg-neutral-900/95 px-3.5 py-2 font-mono text-xs text-neutral-200 shadow-xl backdrop-blur">
588+
<IconDownload className="h-4 w-4 text-green-500" />
589+
<span>Update {updateReady} is ready</span>
590+
<button
591+
onClick={async () => {
592+
if (!window.api?.installUpdate) return;
593+
setInstalling(true);
594+
try {
595+
await window.api.installUpdate();
596+
} catch {
597+
// quitAndInstall normally never returns (the app exits). If it
598+
// rejects, unlock the button so the user can retry.
599+
setInstalling(false);
600+
addNotification({ type: 'info', title: 'Update restart failed', message: 'Try again from the update banner.' });
601+
}
602+
}}
603+
disabled={installing}
604+
className="flex items-center gap-1.5 rounded-sm border border-green-500/50 bg-green-500/10 px-2.5 py-1 text-green-400 hover:bg-green-500/20 disabled:opacity-60"
605+
>
606+
{installing ? <><IconLoader2 className="h-3.5 w-3.5 animate-spin" /> Restarting…</> : 'Restart to update'}
607+
</button>
608+
</div>
609+
)}
566610
{/* Background — flat Off Grid terminal grid (theme-aware), with a dark-mode
567611
starfield + periodic shooting star layered on top. */}
568612
<GridBackdrop className="z-0" />

src/renderer/src/env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ interface IElectronAPI {
206206
onNewApproval: (callback: (data: { approvalId: number; title: string; detail: string; entityName: string | null }) => void) => () => void
207207
onNewAction: (callback: (data: { actionId: number; text: string; due: string | null; entityName: string | null; sourceApp: string }) => void) => () => void
208208
onReprocessProgress: (callback: (data: ReprocessProgress) => void) => () => void
209+
onUpdateDownloaded: (callback: (data: { version: string }) => void) => () => void
210+
getStagedUpdateVersion: () => Promise<string | null>
211+
installUpdate: () => Promise<void>
209212

210213
// Permission APIs
211214
getPermissionStatus: () => Promise<PermissionStatus>

0 commit comments

Comments
 (0)