diff --git a/packages/desktop/src/main/updater/index.test.ts b/packages/desktop/src/main/updater/index.test.ts index d96060ad935..af0d17d4690 100644 --- a/packages/desktop/src/main/updater/index.test.ts +++ b/packages/desktop/src/main/updater/index.test.ts @@ -9,7 +9,8 @@ afterEach(async () => { }) // Drives the updater the way the app does: start or check, then install like a button click. `calls` records the platform -// operations in order; installs record the staged version they would apply. +// operations in order; downloads record whether a differential download was allowed and installs record the staged +// version they would apply. function setup(input?: { currentVersion?: string ready?: { version: string } @@ -29,10 +30,11 @@ function setup(input?: { }, catch: (error) => error, }), - stageUpdate: Effect.tryPromise(async () => { - calls.push("download") - await input?.stage?.() - }), + stageUpdate: (options) => + Effect.tryPromise(async () => { + calls.push(options.differential ? "download" : "download:full") + await input?.stage?.() + }), installAndRestart: Effect.suspend(() => { calls.push(`install:${ready?.version}`) return Effect.tryPromise({ @@ -76,6 +78,7 @@ describe("updater", () => { await app.updater.start() + expect(app.calls).toEqual(["check", "download"]) expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" }) expect(app.getReady()).toEqual({ version: "2.0.0" }) }) @@ -90,15 +93,37 @@ describe("updater", () => { expect(app.getReady()).toBeUndefined() }) - test("revalidates a persisted target through the updater cache on launch", async () => { + test("revalidates a persisted target through the updater cache on launch without a differential download", async () => { const app = setup({ ready: { version: "2.0.0" } }) await app.updater.start() - expect(app.calls).toEqual(["check", "download"]) + expect(app.calls).toEqual(["check", "download:full"]) expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" }) }) + test("keeps differential downloads after the persisted target was installed", async () => { + const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" }, latest: () => "3.0.0" }) + + await app.updater.start() + + expect(app.calls).toEqual(["check", "download"]) + expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" }) + expect(app.getReady()).toEqual({ version: "3.0.0" }) + }) + + test("downloads newer releases in full once one is staged", async () => { + let latest = "2.0.0" + const app = setup({ latest: () => latest }) + await app.updater.start() + + latest = "3.0.0" + await app.updater.check() + + expect(app.calls).toEqual(["check", "download", "check", "download:full"]) + expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" }) + }) + test("concurrent checks share one platform check", async () => { const app = setup() @@ -140,7 +165,7 @@ describe("updater", () => { expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"]) + expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"]) expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" }) }) @@ -220,7 +245,7 @@ describe("updater", () => { await refresh expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" }) await new Promise((resolve) => setTimeout(resolve, 0)) - expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"]) + expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"]) }) test("returns to ready after a failed installation and allows a retry", async () => { diff --git a/packages/desktop/src/main/updater/index.ts b/packages/desktop/src/main/updater/index.ts index 4fc58ccb311..3a2f0500a22 100644 --- a/packages/desktop/src/main/updater/index.ts +++ b/packages/desktop/src/main/updater/index.ts @@ -8,7 +8,7 @@ import { emitIpcEvent } from "../ipc-events" export type Platform = { readonly checkForUpdate: Effect.Effect - readonly stageUpdate: Effect.Effect + readonly stageUpdate: (options: { readonly differential: boolean }) => Effect.Effect readonly installAndRestart: Effect.Effect readonly dispose: () => void } @@ -55,6 +55,22 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc listeners.forEach((listener) => listener(state)) return state } + // electron-updater builds NSIS deltas against the installer of the running version but reads the "old" blockmap from + // the last download. Once a release is staged without installing, the two no longer match and every later delta fails + // its checksum before falling back to a full download, so remember which release the cache holds and skip the attempt. + let downloaded: string | undefined + const stage = (platform: Platform, version: string) => + Effect.gen(function* () { + if (downloaded) + yield* Effect.logInfo("skipping differential download, updater cache is stale", { + current: dependencies.currentVersion, + staged: downloaded, + version, + }) + yield* platform.stageUpdate({ differential: !downloaded }) + downloaded = version + yield* dependencies.persistence.set({ version }) + }) const findAndStage = (platform: Platform) => Effect.gen(function* () { yield* Effect.sync(() => transition({ status: "checking" })) @@ -64,8 +80,7 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc return transition({ status: "up-to-date" }) } transition({ status: "downloading", version }) - yield* platform.stageUpdate - yield* dependencies.persistence.set({ version }) + yield* stage(platform, version) return transition({ status: "ready", version }) }).pipe( Effect.catch((error) => @@ -78,8 +93,7 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc Effect.gen(function* () { const version = yield* platform.checkForUpdate if (!version || version === staged || version === dependencies.currentVersion) return state - yield* platform.stageUpdate - yield* dependencies.persistence.set({ version }) + yield* stage(platform, version) return transition({ status: installing ? "installing" : "ready", version }) }).pipe( Effect.catch((error) => @@ -137,6 +151,8 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc const start = Effect.gen(function* () { const ready = yield* dependencies.persistence.get if (ready?.version === dependencies.currentVersion) yield* dependencies.persistence.clear + // Any other persisted target was downloaded by an earlier launch and never installed, so its blockmap is cached. + if (ready && ready.version !== dependencies.currentVersion) downloaded = ready.version yield* check }) const unsubscribe = (id: number) => { diff --git a/packages/desktop/src/main/updater/platform.ts b/packages/desktop/src/main/updater/platform.ts index 8033870365d..31354c13de0 100644 --- a/packages/desktop/src/main/updater/platform.ts +++ b/packages/desktop/src/main/updater/platform.ts @@ -37,16 +37,21 @@ export const make = Effect.gen(function* () { }, catch: (error) => error, }), - stageUpdate: stageUpdate(), + stageUpdate, installAndRestart, dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit), } satisfies Platform }) -function stageUpdate() { +function stageUpdate(options: { readonly differential: boolean }) { if (process.platform !== "darwin") return Effect.tryPromise({ - try: () => updateClient.downloadUpdate(), + try: () => { + // Only the NSIS cache goes stale: macOS refreshes its cached zip with every download and AppImage reads the + // blockmap embedded in the running file. + updateClient.disableDifferentialDownload = process.platform === "win32" && !options.differential + return updateClient.downloadUpdate() + }, catch: (error) => error, }).pipe(Effect.asVoid)