Skip to content

Commit 75d6003

Browse files
authored
Update macOS over the air: download in the background, install on quit (#317)
* Update macOS over the air: download in the background, install on quit A check that finds a newer release now downloads and verifies it without a click. The banner then says the update is ready and offers Restart to update. An update nobody restarted into installs the next time Modex quits: the quit runs the same restart preflight with relaunch off, Squirrel installs the staged bundle as Modex exits, and Modex does not reopen. A refused preflight (a running turn, an open page) or a quit with the window closed only skips the install. The release page is now a "What's new" link, never the way to update. A cancelled download stays cancelled for that version, and a failed automatic one is retried by checks only after an hour. A ready update can be dismissed because it still installs on quit. Windows and Linux are unchanged. The native acceptance no longer clicks Download before the tampered-archive check; it waits for the automatic download to fetch it. Coven-Harness: Claude Code / claude-opus-5-5 * Name the over-the-air update PR in the status page Coven-Harness: Claude Code / claude-opus-5-5
1 parent 9a2e15f commit 75d6003

15 files changed

Lines changed: 293 additions & 56 deletions

‎README.md‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,17 @@ For Electron development, also run
210210
## Release notifications
211211

212212
Starting with v0.0.7, installed macOS builds check for a newer stable release at launch and hourly while the
213-
workspace is visible, with a check when you return to it. A small banner links to the
214-
release page; dismissing it hides that version across relaunches. A later release appears
215-
again. Checks use public GitHub release metadata and require an uploaded installer for
216-
your Mac. Offline failures stay quiet. From v0.0.12 the banner also offers **Download update**:
217-
Modex verifies the archive and its publisher, then offers **Restart to update** and keeps your
218-
drafts. Builds before v0.0.12 install it manually once. Windows and Linux show no update banner;
219-
install a newer release by hand. See [update preparation and draft recovery](docs/update-recovery.md).
213+
workspace is visible, with a check when you return to it. Checks use public GitHub release
214+
metadata and require an uploaded installer for your Mac. Offline failures stay quiet.
215+
216+
After v0.2.0, updates arrive over the air. When a check finds a newer release, Modex downloads
217+
it and verifies the archive and its publisher in the background. The banner then offers
218+
**Restart to update**, which keeps your drafts. If you never click it, the update installs the
219+
next time you quit Modex. **What’s new** opens the release notes; you never need the release
220+
page to update. Dismissing the banner hides that version across relaunches, and a dismissed
221+
update still installs when you quit. v0.0.12 through v0.2.0 ask you to click **Download update**
222+
first, and builds before v0.0.12 install a newer release manually once. Windows and Linux show no
223+
update banner; install a newer release by hand. See [update preparation and draft recovery](docs/update-recovery.md).
220224

221225
If you are on v0.0.6 or earlier, install v0.0.7 from the release page once to enable future
222226
update notifications; older builds do not contain the banner.

‎apps/desktop/e2e/fixtures/update-installation.mjs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ safeStorage.encryptString = value => Buffer.from(value);
2222
safeStorage.decryptString = value => value.toString();
2323
shell.openExternal = async () => { throw new Error("External navigation is disabled in this fixture."); };
2424
globalThis.fetch = async () => { throw new Error("Network is disabled in this fixture."); };
25-
const scenario = globalThis.__updateScenario = { downloads: 0, downloadedDisposed: 0, stagedDisposed: 0, nativeCalls: 0 };
25+
const scenario = globalThis.__updateScenario = { downloads: 0, downloadedDisposed: 0, stagedDisposed: 0, nativeCalls: 0, quitStages: 0 };
2626
const forbidNative = () => { scenario.nativeCalls++; throw new Error("Native installation is forbidden in this fixture."); };
2727
for (const name of ["setFeedURL", "checkForUpdates", "quitAndInstall"]) autoUpdater[name] = forbidNative;
28-
MacUpdateHandoff.prototype.stage = forbidNative;
28+
// Install-on-quit reaches this boundary after the real preflight and drain. Record the
29+
// durable receipt as Squirrel's handoff would, but never touch the native updater.
30+
MacUpdateHandoff.prototype.stage = async (snapshot, recordAttempt) => {
31+
scenario.quitStages++;
32+
await recordAttempt({ version: snapshot.version, sha512: snapshot.sha512, startedAt: new Date().toISOString() });
33+
return "staged";
34+
};
2935
const release = { version: "999.0.0", url: "https://github.com/TypeSafeAI/modex/releases/tag/v999.0.0" };
3036
ReleaseChecker.prototype.check = async () => release;
3137
ReleaseChecker.prototype.archive = () => ({ version: release.version, arch: process.arch, size: 100, sha256: "ab".repeat(32) });

‎apps/desktop/e2e/update-installation.spec.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,19 @@ async function counts(app: ElectronApplication) {
2626
});
2727
}
2828

29+
/** Quits, checks the evidence every test shares and returns it for the test's own checks. */
2930
async function cleanup(app: ElectronApplication, home: string, repo: string) {
31+
let final: { nativeCalls: number; downloadedDisposed: number; stagedDisposed: number; quitStages: number } | undefined;
3032
try {
3133
// An assertion can fail while the fake verifier is suspended. Release it so
3234
// production disposal can finish and the failed test cannot retain Electron.
3335
await app.evaluate(() => (globalThis as any).__updateScenario?.finishVerification?.(false));
3436
} finally {
3537
try {
3638
await app.close();
37-
const final = JSON.parse(fs.readFileSync(path.join(home, "final-installation-evidence.json"), "utf8"));
38-
expect(final.nativeCalls).toBe(0); expect(final.downloadedDisposed).toBe(final.stagedDisposed);
39+
final = JSON.parse(fs.readFileSync(path.join(home, "final-installation-evidence.json"), "utf8"));
40+
// quitAndInstall relaunches; install-on-quit only stages and lets the quit finish.
41+
expect(final!.nativeCalls).toBe(0); expect(final!.downloadedDisposed).toBe(final!.stagedDisposed);
3942
const receipt = test.info().outputPath("installation-controller-evidence.json");
4043
fs.writeFileSync(receipt, JSON.stringify(final));
4144
await test.info().attach("installation-controller-evidence", { path: receipt, contentType: "application/json" });
@@ -45,28 +48,34 @@ async function cleanup(app: ElectronApplication, home: string, repo: string) {
4548
fs.rmSync(repo, { recursive: true, force: true });
4649
}
4750
}
51+
return final!;
4852
}
4953

50-
test("installation UI shows progress, cancels and retries, then refuses restart with open Settings", async () => {
54+
test("installation downloads by itself, cancels and retries, refuses restart with open Settings, then installs on quit", async () => {
5155
test.skip(process.platform !== "darwin", "macOS native update controller");
5256
const { home, repo, app, page } = await launchInstallation();
57+
let final;
5358
try {
5459
const banner = tid(page, "update-banner");
55-
await expect(banner.getByRole("button", { name: "Download update", exact: true })).toBeVisible();
56-
await banner.getByRole("button", { name: "Download update", exact: true }).click();
60+
// Nobody clicks: finding the release starts the download.
5761
await expect(page.getByRole("progressbar", { name: "Update download" })).toHaveAttribute("value", "25");
5862
await expect(banner).toContainText("Downloading the update…");
5963
await expect(banner.getByRole("button", { name: "Dismiss update notification" })).toHaveCount(0);
6064
await banner.getByRole("button", { name: "Cancel download", exact: true }).click();
6165
await expect(banner).toContainText("Update preparation cancelled.");
66+
// A cancelled version stays cancelled when the window regains focus.
67+
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
68+
await expect(banner.getByRole("button", { name: "Download update", exact: true })).toBeVisible();
69+
expect((await counts(app)).downloads).toBe(1);
6270
await banner.getByRole("button", { name: "Download update", exact: true }).click();
6371
await expect(banner).toContainText("Downloading the update…");
6472
await finishDownload(app);
6573
await expect(banner).toContainText("Verifying the publisher and update…");
6674
await finishVerification(app, true);
6775
await expect(banner.getByRole("button", { name: "Restart to update", exact: true })).toBeEnabled();
68-
await expect(banner).toContainText("Verified and ready.");
69-
await expect(banner.getByRole("button", { name: "Dismiss update notification" })).toHaveCount(0);
76+
await expect(banner).toContainText("Modex 999.0.0 is ready to install");
77+
await expect(banner).toContainText("installs the next time you quit");
78+
await expect(banner.getByRole("link", { name: "What’s new" })).toHaveAttribute("href", "https://github.com/TypeSafeAI/modex/releases/tag/v999.0.0");
7079
await tid(page, "open-settings").click();
7180
await expect(page.getByRole("dialog", { name: "Settings" })).toBeVisible();
7281
// Settings may still have admitted a model/health request. Its ordinary-work
@@ -84,25 +93,24 @@ test("installation UI shows progress, cancels and retries, then refuses restart
8493
expect(await counts(app)).toEqual({ downloads: 2, downloadedDisposed: 1, stagedDisposed: 0, nativeCalls: 0 });
8594
await banner.screenshot({ path: test.info().outputPath("update-ready-after-refusal.png") });
8695
} finally {
87-
await cleanup(app, home, repo);
96+
final = await cleanup(app, home, repo);
8897
}
98+
expect(final.quitStages).toBe(1);
8999
});
90100

91101
test("failed publisher verification offers retry and never exposes an install action", async () => {
92102
test.skip(process.platform !== "darwin", "macOS native update controller");
93103
const { home, repo, app, page } = await launchInstallation();
94104
try {
95105
const banner = tid(page, "update-banner");
96-
await expect(banner.getByRole("button", { name: "Download update", exact: true })).toBeVisible();
97-
await banner.getByRole("button", { name: "Download update", exact: true }).click();
98106
await expect(banner).toContainText("Downloading the update…");
99107
await finishDownload(app);
100108
await expect(banner).toContainText("Verifying the publisher and update…");
101109
await finishVerification(app, false);
102110
await expect(banner).toContainText("could not be downloaded or verified");
103111
await expect(tid(banner, "update-error-detail")).toHaveText("Publisher verification failed");
104112
await expect(banner.getByRole("button", { name: "Restart to update", exact: true })).toHaveCount(0);
105-
await expect(banner.getByRole("link", { name: "View update" })).toHaveAttribute("href", "https://github.com/TypeSafeAI/modex/releases/tag/v999.0.0");
113+
await expect(banner.getByRole("link", { name: "What’s new" })).toHaveAttribute("href", "https://github.com/TypeSafeAI/modex/releases/tag/v999.0.0");
106114
await expect.poll(() => counts(app)).toEqual({ downloads: 1, downloadedDisposed: 1, stagedDisposed: 1, nativeCalls: 0 });
107115
await banner.getByRole("button", { name: "Download update", exact: true }).click();
108116
await expect(banner).toContainText("Downloading the update…");

‎apps/desktop/e2e/update-main.spec.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ test("renderer reload after an updater partial drain opens read-only and ordinar
1717
const page = await app.firstWindow(); await page.waitForLoadState("domcontentloaded");
1818
page.on("dialog", dialog => { if (dialog.type() !== "beforeunload") void dialog.dismiss(); });
1919
await tid(page, "composer-input").fill(raw);
20-
await page.getByRole("button", { name: "Download update", exact: true }).click();
21-
await page.getByRole("button", { name: "Restart to update", exact: true }).click();
22-
await expect(tid(page, "update-restart-status")).toContainText("could not finish preparing to restart");
20+
// The update downloaded by itself; only the restart needs a click. Right after launch a
21+
// startup request can still be admitted, and that refusal keeps the update ready to retry.
22+
const restart = page.getByRole("button", { name: "Restart to update", exact: true }), status = tid(page, "update-restart-status");
23+
await expect(async () => {
24+
if (await restart.isVisible()) await restart.click();
25+
await expect(status).toBeVisible({ timeout: 2000 });
26+
}).toPass({ timeout: 20_000 });
27+
await expect(status).toContainText("could not finish preparing to restart");
2328
const before = fs.readFileSync(file); expect(JSON.parse(before.toString()).drafts.projects[0].text).toBe(raw);
2429
await page.reload();
2530
await expect(tid(page, "update-recovery")).toBeVisible();
@@ -77,7 +82,7 @@ test("actual main restart retains drafts after partial drain and retries fresh r
7782
await page.getByRole("textbox", { name: "Page title" }).fill("Dirty page saved through main restart admission");
7883
await expect(page.getByLabel("Save status")).toHaveText("Not saved");
7984
await page.getByRole("button", { name: "Chat", exact: true }).click();
80-
await page.getByRole("button", { name: "Download update", exact: true }).click();
85+
await expect(page.getByRole("button", { name: "Restart to update", exact: true })).toBeVisible();
8186
unblockNoteWrites(home);
8287
await page.getByRole("button", { name: "Restart to update", exact: true }).click();
8388
const panel = tid(page, "update-restart-status");

‎apps/desktop/e2e/updates.spec.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ for (const theme of ["jev", "coven"] as const) test(`${theme}: update banner ope
3030
await offer("999.0.0");
3131
const banner = tid(page, "update-banner");
3232
await expect(banner).toContainText("Modex 999.0.0 is available");
33-
const download = banner.getByRole("link", { name: "View update" });
33+
const download = banner.getByRole("link", { name: "What’s new" });
3434
await expect(download).toHaveAttribute("href", "https://github.com/TypeSafeAI/modex/releases/tag/v999.0.0");
3535
await app.evaluate(({ BrowserWindow }) => {
3636
BrowserWindow.getAllWindows()[0]!.webContents.setWindowOpenHandler(({ url }) => {
@@ -59,8 +59,8 @@ for (const theme of ["jev", "coven"] as const) test(`${theme}: update banner ope
5959
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
6060
await expect(restored).toContainText("Modex 999.0.2 is available");
6161
await resizeWindow(app, page, 900, 600);
62-
await restored.getByRole("link", { name: "View update" }).focus();
63-
await expect(restored.getByRole("link", { name: "View update" })).toBeFocused();
62+
await restored.getByRole("link", { name: "What’s new" }).focus();
63+
await expect(restored.getByRole("link", { name: "What’s new" })).toBeFocused();
6464
await page.keyboard.press("Tab");
6565
await expect(restored.getByRole("button", { name: "Dismiss update notification" })).toBeFocused();
6666
const fits = await restored.evaluate((element) => {
@@ -73,7 +73,7 @@ for (const theme of ["jev", "coven"] as const) test(`${theme}: update banner ope
7373
expect(fits).toBe(true);
7474
await page.screenshot({ path: test.info().outputPath(`update-banner-${theme}-compact.png`) });
7575
await page.emulateMedia({ reducedMotion: "reduce" });
76-
await expect(restored.getByRole("link", { name: "View update" })).toHaveCSS("transition-duration", "0s");
76+
await expect(restored.getByRole("link", { name: "What’s new" })).toHaveCSS("transition-duration", "0s");
7777
await offer(null);
7878
await expect(restored).toHaveCount(0);
7979
});

‎apps/desktop/scripts/native-update-acceptance.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,13 @@ export async function runNativeUpdateAcceptance({ sourceApp, candidateArchive, v
181181
await page.evaluate(() => window.modex.invoke("updates:check", undefined));
182182
const banner = page.getByTestId("update-banner");
183183
await page.getByTestId("composer-input").fill(draft);
184-
await banner.getByRole("button", { name: "Download update", exact: true }).click();
184+
// Finding the release starts the download without a click; the tampered archive is refused.
185+
const transport = () => fs.existsSync(transportFile) ? fs.readFileSync(transportFile, "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : [];
186+
await expect.poll(() => transport().some(entry => entry.request === "candidate-archive" && entry.corrupt), { timeout: 180_000 }).toBe(true);
185187
await expect(banner).toContainText("could not be downloaded or verified", { timeout: 180_000 });
186188
assert.equal(JSON.parse(fs.readFileSync(receiptFile, "utf8")).attempt, null);
187189
assert.equal(fs.existsSync(eventsFile), false);
188-
proof.checks.push("tampered download refused before native admission");
190+
proof.checks.push("automatic download of a tampered archive refused before native admission");
189191
await app.evaluate(() => { globalThis.__nativeUpdateAcceptance.corrupt = false; });
190192
await banner.getByRole("button", { name: "Download update", exact: true }).click();
191193
await expect(banner.getByRole("button", { name: "Restart to update", exact: true })).toBeEnabled({ timeout: 180_000 });

0 commit comments

Comments
 (0)