diff --git a/bun.lock b/bun.lock index 2e0685cdf..893d04885 100644 --- a/bun.lock +++ b/bun.lock @@ -90,6 +90,7 @@ "electron-updater": "^6", "electron-window-state": "^5.0.3", "marked": "^15", + "semver": "^7.6.0", }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -102,6 +103,7 @@ "@solidjs/router": "0.15.4", "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/semver": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", "electron": "40.8.0", diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 6122b4d92..cb894219a 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -149,13 +149,12 @@ export const SettingsGeneral: Component = () => { } const actions = - platform.update && platform.restart + platform.update ? [ { label: language.t("toast.update.action.installRestart"), onClick: async () => { await platform.update!() - await platform.restart!() }, }, { diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b2fcf927b..33781675c 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -7,7 +7,7 @@ type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type SaveFilePickerOptions = { title?: string; defaultPath?: string } -type UpdateFailureReason = "check" | "download" | "metadata" +type UpdateFailureReason = "check" | "download" | "metadata" | "cache" export type UpdateInfo = | { updateAvailable: false; status: "disabled" | "none" | "busy"; version?: undefined } | { updateAvailable: true; status: "ready"; version: string } diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 803b881f1..263f8d48d 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -264,10 +264,9 @@ export const ErrorPage: Component = (props) => { } async function installUpdate() { - if (!platform.update || !platform.restart) return + if (!platform.update) return await platform .update() - .then(() => platform.restart!()) .then(() => setStore({ actionError: undefined, actionMessage: undefined })) .catch((err) => { setStore({ actionError: formatError(err, language.t), actionMessage: undefined }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index abdf95972..ef92f1c7c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -367,7 +367,7 @@ export default function Layout(props: ParentProps) { const useUpdatePolling = () => onMount(() => { - if (!platform.checkUpdate || !platform.update || !platform.restart) return + if (!platform.checkUpdate || !platform.update) return let toastId: number | undefined let interval: ReturnType | undefined @@ -386,7 +386,6 @@ export default function Layout(props: ParentProps) { label: language.t("toast.update.action.installRestart"), onClick: async () => { await platform.update!() - await platform.restart!() }, }, { diff --git a/packages/app/src/pages/update-install-flow-source.test.ts b/packages/app/src/pages/update-install-flow-source.test.ts new file mode 100644 index 000000000..e6c3fc7ef --- /dev/null +++ b/packages/app/src/pages/update-install-flow-source.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const layout = readFileSync(new URL("./layout.tsx", import.meta.url), "utf8") +const errorPage = readFileSync(new URL("./error.tsx", import.meta.url), "utf8") +const settings = readFileSync(new URL("../components/settings-general.tsx", import.meta.url), "utf8") +const platform = readFileSync(new URL("../context/platform.tsx", import.meta.url), "utf8") + +describe("update install renderer contracts", () => { + test("renderer install actions do not relaunch after platform update", () => { + expect(layout).not.toMatch(/await\s+platform\.restart!\(\)/) + expect(settings).not.toMatch(/await\s+platform\.restart!\(\)/) + expect(errorPage).not.toMatch(/await\s+platform\.restart!\(\)/) + expect(layout).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) + expect(settings).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) + expect(errorPage).not.toMatch(/\.then\(\(\)\s*=>\s*platform\.restart!\(\)\)/) + }) + + test("renderer update prompts only require platform update", () => { + expect(layout).toContain("if (!platform.checkUpdate || !platform.update) return") + expect(layout).not.toContain("if (!platform.checkUpdate || !platform.update || !platform.restart) return") + expect(settings).toContain("platform.update") + expect(settings).not.toContain("platform.update && platform.restart") + }) + + test("cache update failures are part of the renderer-facing type", () => { + expect(platform).toContain('"check" | "download" | "metadata" | "cache"') + }) +}) diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index c9dca2af5..51725e12e 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -34,7 +34,8 @@ "electron-store": "^10", "electron-updater": "^6", "electron-window-state": "^5.0.3", - "marked": "^15" + "marked": "^15", + "semver": "^7.6.0" }, "devDependencies": { "@actions/artifact": "4.0.0", @@ -45,6 +46,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", + "@types/semver": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/desktop-electron/scripts/write-app-update-config.test.ts b/packages/desktop-electron/scripts/write-app-update-config.test.ts index 6c6adba35..24f0681da 100644 --- a/packages/desktop-electron/scripts/write-app-update-config.test.ts +++ b/packages/desktop-electron/scripts/write-app-update-config.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { UPDATER_CACHE_DIR_NAME } from "../src/main/updater-cache" import { serializeAppUpdateConfig, writeAppUpdateConfig } from "./write-app-update-config" const roots: string[] = [] @@ -26,12 +27,23 @@ describe("write-app-update-config", () => { "owner: Astro-Han", "repo: pawwork", "channel: latest", - "updaterCacheDirName: pawwork-updater", + `updaterCacheDirName: ${UPDATER_CACHE_DIR_NAME}`, "", ].join("\n"), ) }) + test("serializes updater cache dir from the shared constant", () => { + expect( + serializeAppUpdateConfig({ + provider: "github", + owner: "Astro-Han", + repo: "pawwork", + channel: "latest", + }), + ).toContain(`updaterCacheDirName: ${UPDATER_CACHE_DIR_NAME}`) + }) + test("serializes beta GitHub updater config", () => { expect( serializeAppUpdateConfig({ @@ -65,7 +77,8 @@ describe("write-app-update-config", () => { ).toBe(true) const configPath = join(root, "PawWork.app", "Contents", "Resources", "app-update.yml") - expect(readFileSync(configPath, "utf8")).toContain("repo: pawwork") - expect(readFileSync(configPath, "utf8")).toContain("updaterCacheDirName: pawwork-updater") + const config = readFileSync(configPath, "utf8") + expect(config).toContain("repo: pawwork") + expect(config).toContain(`updaterCacheDirName: ${UPDATER_CACHE_DIR_NAME}`) }) }) diff --git a/packages/desktop-electron/scripts/write-app-update-config.ts b/packages/desktop-electron/scripts/write-app-update-config.ts index 7bd958e86..4074e1cbf 100644 --- a/packages/desktop-electron/scripts/write-app-update-config.ts +++ b/packages/desktop-electron/scripts/write-app-update-config.ts @@ -1,6 +1,8 @@ import { mkdir, writeFile } from "node:fs/promises" import { join } from "node:path" +import { UPDATER_CACHE_DIR_NAME } from "../src/main/updater-cache" + export type GitHubPublishConfig = { provider: "github" owner: string @@ -8,8 +10,6 @@ export type GitHubPublishConfig = { channel: string } -const UPDATER_CACHE_DIR_NAME = "pawwork-updater" - export function serializeAppUpdateConfig(publish: GitHubPublishConfig) { return [ "provider: github", diff --git a/packages/desktop-electron/src/main/index-updater-source.test.ts b/packages/desktop-electron/src/main/index-updater-source.test.ts new file mode 100644 index 000000000..4cf907a7f --- /dev/null +++ b/packages/desktop-electron/src/main/index-updater-source.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8") + +describe("main updater source contracts", () => { + test("disables stable downgrades after assigning latest channel", () => { + const channelIndex = source.search(/autoUpdater\.channel\s*=\s*"latest"/) + const downgradeIndex = source.search(/autoUpdater\.allowDowngrade\s*=\s*false/) + expect(channelIndex).toBeGreaterThanOrEqual(0) + expect(downgradeIndex).toBeGreaterThan(channelIndex) + expect(source).not.toContain("autoUpdater.allowDowngrade = true") + }) + + test("disables auto install on quit only on macOS", () => { + expect(source).toContain('autoUpdater.autoInstallOnAppQuit = process.platform !== "darwin"') + expect(source).not.toContain("autoUpdater.autoInstallOnAppQuit = false") + }) + + test("strict pending cleanup uses shared updater cache helper and propagates rm errors", () => { + expect(source).toContain('import { pendingUpdateCacheDir } from "./updater-cache"') + expect(source).toContain("await rm(pendingUpdateCacheDir(), { recursive: true, force: true })") + expect(source).not.toMatch( + /rm\(pendingUpdateCacheDir\(\),\s*\{\s*recursive:\s*true,\s*force:\s*true\s*\}\)\s*\.catch\(\(\)\s*=>/, + ) + }) +}) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index f49a3d0ff..a061da351 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" import { mkdirSync, writeFileSync } from "node:fs" +import { rm } from "node:fs/promises" import { createServer } from "node:net" import os, { homedir } from "node:os" import { dirname, join } from "node:path" @@ -61,6 +62,7 @@ import { readStoredMenuLocale, writeStoredMenuLocale } from "./menu-i18n" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { PAWWORK_RUNTIME } from "./runtime-namespace" import { createUpdaterController } from "./updater" +import { pendingUpdateCacheDir } from "./updater-cache" import { updaterDialogLabels } from "./updater-dialog-labels" import { createLoadingWindow, @@ -106,6 +108,7 @@ const updater = createUpdaterController({ currentVersion: () => app.getVersion(), checkForUpdates: () => autoUpdater.checkForUpdates(), downloadUpdate: () => autoUpdater.downloadUpdate(), + clearPendingUpdate: clearPendingUpdate, quitAndInstall: () => { killSidecar() autoUpdater.quitAndInstall() @@ -544,18 +547,23 @@ async function getSidecarPort() { }) } +async function clearPendingUpdate() { + await rm(pendingUpdateCacheDir(), { recursive: true, force: true }) +} + function setupAutoUpdater() { if (!UPDATER_ENABLED) return autoUpdater.logger = logger autoUpdater.channel = "latest" autoUpdater.allowPrerelease = false - autoUpdater.allowDowngrade = true + autoUpdater.allowDowngrade = false autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = true + autoUpdater.autoInstallOnAppQuit = process.platform !== "darwin" logger.log("auto updater configured", { channel: autoUpdater.channel, allowPrerelease: autoUpdater.allowPrerelease, allowDowngrade: autoUpdater.allowDowngrade, + autoInstallOnAppQuit: autoUpdater.autoInstallOnAppQuit, currentVersion: app.getVersion(), }) } diff --git a/packages/desktop-electron/src/main/updater-cache.test.ts b/packages/desktop-electron/src/main/updater-cache.test.ts new file mode 100644 index 000000000..ae2a4f508 --- /dev/null +++ b/packages/desktop-electron/src/main/updater-cache.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { getAppCacheDir, pendingUpdateCacheDir, UPDATER_CACHE_DIR_NAME } from "./updater-cache" + +describe("updater cache path", () => { + test("uses the same updater cache dir name as app-update.yml", () => { + expect(UPDATER_CACHE_DIR_NAME).toBe("pawwork-updater") + }) + + test("resolves macOS pending cache from the user library cache root", () => { + expect(getAppCacheDir({ platform: "darwin", homedir: "/Users/demo", env: {} })).toBe("/Users/demo/Library/Caches") + expect(pendingUpdateCacheDir({ platform: "darwin", homedir: "/Users/demo", env: {} })).toBe( + "/Users/demo/Library/Caches/pawwork-updater/pending", + ) + }) + + test("resolves Windows pending cache from LOCALAPPDATA when present", () => { + expect( + pendingUpdateCacheDir({ + platform: "win32", + homedir: "C:\\Users\\demo", + env: { LOCALAPPDATA: "C:\\Users\\demo\\AppData\\Local" }, + }), + ).toBe("C:\\Users\\demo\\AppData\\Local\\pawwork-updater\\pending") + }) + + test("resolves Windows pending cache from localappdata casing when present", () => { + expect( + pendingUpdateCacheDir({ + platform: "win32", + homedir: "C:\\Users\\demo", + env: { localappdata: "D:\\Cache" }, + }), + ).toBe("D:\\Cache\\pawwork-updater\\pending") + }) + + test("falls back to AppData\\Local on Windows when LOCALAPPDATA is missing", () => { + expect(pendingUpdateCacheDir({ platform: "win32", homedir: "C:\\Users\\demo", env: {} })).toBe( + "C:\\Users\\demo\\AppData\\Local\\pawwork-updater\\pending", + ) + }) + + test("ignores relative Windows cache roots from env", () => { + expect( + pendingUpdateCacheDir({ + platform: "win32", + homedir: "C:\\Users\\demo", + env: { LOCALAPPDATA: "relative-cache" }, + }), + ).toBe("C:\\Users\\demo\\AppData\\Local\\pawwork-updater\\pending") + }) + + test("uses lowercase Windows cache root when uppercase env is relative", () => { + expect( + pendingUpdateCacheDir({ + platform: "win32", + homedir: "C:\\Users\\demo", + env: { LOCALAPPDATA: "relative-cache", localappdata: "D:\\Cache" }, + }), + ).toBe("D:\\Cache\\pawwork-updater\\pending") + }) + + test("resolves Linux pending cache from XDG_CACHE_HOME when present", () => { + expect( + pendingUpdateCacheDir({ platform: "linux", homedir: "/home/demo", env: { XDG_CACHE_HOME: "/tmp/cache" } }), + ).toBe("/tmp/cache/pawwork-updater/pending") + }) + + test("falls back to ~/.cache on Linux when XDG_CACHE_HOME is missing", () => { + expect(pendingUpdateCacheDir({ platform: "linux", homedir: "/home/demo", env: {} })).toBe( + "/home/demo/.cache/pawwork-updater/pending", + ) + }) + + test("ignores relative Linux cache roots from env", () => { + expect( + pendingUpdateCacheDir({ platform: "linux", homedir: "/home/demo", env: { XDG_CACHE_HOME: "relative-cache" } }), + ).toBe("/home/demo/.cache/pawwork-updater/pending") + }) +}) diff --git a/packages/desktop-electron/src/main/updater-cache.ts b/packages/desktop-electron/src/main/updater-cache.ts new file mode 100644 index 000000000..4b15a98ac --- /dev/null +++ b/packages/desktop-electron/src/main/updater-cache.ts @@ -0,0 +1,44 @@ +import { homedir as currentHomedir } from "node:os" +import path from "node:path" + +export const UPDATER_CACHE_DIR_NAME = "pawwork-updater" + +type CacheInput = { + platform?: NodeJS.Platform + homedir?: string + env?: NodeJS.ProcessEnv +} + +function pathForPlatform(platform: NodeJS.Platform) { + return platform === "win32" ? path.win32 : path.posix +} + +function firstAbsolutePath( + platformPath: typeof path.posix | typeof path.win32, + fallback: string, + ...values: Array +) { + return values.find((value) => value && platformPath.isAbsolute(value)) ?? fallback +} + +export function getAppCacheDir(input: CacheInput = {}) { + const platform = input.platform ?? process.platform + const homedir = input.homedir ?? currentHomedir() + const env = input.env ?? process.env + const platformPath = pathForPlatform(platform) + + if (platform === "win32") + return firstAbsolutePath( + platformPath, + platformPath.join(homedir, "AppData", "Local"), + env.LOCALAPPDATA, + env.localappdata, + ) + if (platform === "darwin") return platformPath.join(homedir, "Library", "Caches") + return firstAbsolutePath(platformPath, platformPath.join(homedir, ".cache"), env.XDG_CACHE_HOME) +} + +export function pendingUpdateCacheDir(input: CacheInput = {}) { + const platform = input.platform ?? process.platform + return pathForPlatform(platform).join(getAppCacheDir(input), UPDATER_CACHE_DIR_NAME, "pending") +} diff --git a/packages/desktop-electron/src/main/updater.test.ts b/packages/desktop-electron/src/main/updater.test.ts index 5ca6983c3..dfa130dbe 100644 --- a/packages/desktop-electron/src/main/updater.test.ts +++ b/packages/desktop-electron/src/main/updater.test.ts @@ -2,14 +2,16 @@ import { describe, expect, test } from "bun:test" import { createUpdaterController } from "./updater" function controller(overrides: Partial[0]> = {}) { + let currentVersion = "0.2.4" const calls = { check: 0, download: 0, install: 0, + clearPending: 0, } const deps = { enabled: true, - currentVersion: () => "0.2.4", + currentVersion: () => currentVersion, checkForUpdates: async () => { calls.check += 1 return { isUpdateAvailable: false, updateInfo: { version: "0.2.4", files: [] } } @@ -17,6 +19,9 @@ function controller(overrides: Partial { calls.download += 1 }, + clearPendingUpdate: async () => { + calls.clearPending += 1 + }, quitAndInstall: () => { calls.install += 1 }, @@ -28,6 +33,9 @@ function controller(overrides: Partial { }) }) + test("does not download provider downgrade even if provider marks it available", async () => { + const setup = controller({ + currentVersion: () => "0.2.8", + checkForUpdates: async () => { + setup.calls.check += 1 + return { isUpdateAvailable: true, updateInfo: { version: "0.2.7", files: [{ url: "old.zip" }] } } + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ status: "none" }) + expect(setup.calls.download).toBe(0) + expect(setup.calls.clearPending).toBe(1) + expect(setup.calls.check).toBe(2) + }) + + test("clears stale pending metadata once and rechecks for fresh update", async () => { + const setup = controller({ + currentVersion: () => "0.2.8", + checkForUpdates: async () => { + setup.calls.check += 1 + if (setup.calls.check === 1) { + return { isUpdateAvailable: true, updateInfo: { version: "0.2.7", files: [{ url: "old.zip" }] } } + } + return { isUpdateAvailable: true, updateInfo: { version: "0.2.9", files: [{ url: "new.zip" }] } } + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.9" }) + expect(setup.calls.clearPending).toBe(1) + expect(setup.calls.download).toBe(1) + expect(setup.calls.check).toBe(2) + }) + + test("fails closed when stale pending metadata cleanup fails", async () => { + const setup = controller({ + currentVersion: () => "0.2.8", + checkForUpdates: async () => { + setup.calls.check += 1 + return { isUpdateAvailable: true, updateInfo: { version: "0.2.7", files: [{ url: "old.zip" }] } } + }, + clearPendingUpdate: async () => { + setup.calls.clearPending += 1 + throw new Error("permission denied") + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "cache", + message: "permission denied", + }) + expect(setup.calls.download).toBe(0) + expect(setup.calls.check).toBe(1) + }) + + test("clears stale ready update before fresh recheck", async () => { + const setup = controller({ + checkForUpdates: async () => { + setup.calls.check += 1 + if (setup.calls.check === 1) { + return { isUpdateAvailable: true, updateInfo: { version: "0.2.9", files: [{ url: "new.zip" }] } } + } + return { isUpdateAvailable: false, updateInfo: { version: "0.2.9", files: [] } } + }, + }) + + setup.setCurrentVersion("0.2.8") + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.9" }) + setup.setCurrentVersion("0.2.9") + await expect(setup.updater.check()).resolves.toEqual({ status: "none" }) + expect(setup.calls.clearPending).toBe(1) + expect(setup.calls.check).toBe(2) + }) + + test("fails closed when stale ready cache cleanup fails", async () => { + const setup = controller({ + checkForUpdates: async () => { + setup.calls.check += 1 + return { isUpdateAvailable: true, updateInfo: { version: "0.2.9", files: [{ url: "new.zip" }] } } + }, + clearPendingUpdate: async () => { + setup.calls.clearPending += 1 + throw new Error("permission denied") + }, + }) + + setup.setCurrentVersion("0.2.8") + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.9" }) + setup.setCurrentVersion("0.2.9") + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "cache", + message: "permission denied", + }) + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "cache", + message: "permission denied", + }) + expect(setup.calls.clearPending).toBe(2) + expect(setup.calls.check).toBe(1) + }) + + test("does not install stale ready update if current version catches up", async () => { + const setup = controller({ + checkForUpdates: async () => { + setup.calls.check += 1 + return { isUpdateAvailable: true, updateInfo: { version: "0.2.9", files: [{ url: "new.zip" }] } } + }, + }) + + setup.setCurrentVersion("0.2.8") + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.9" }) + setup.setCurrentVersion("0.2.9") + expect(setup.updater.install()).toBe(false) + expect(setup.calls.install).toBe(0) + }) + + test("keeps semver-newer ready update even when string order would be wrong", async () => { + const setup = controller({ + currentVersion: () => "0.2.9", + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { version: "0.2.10", files: [{ url: "app.zip" }] }, + }), + }) + + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.10" }) + await expect(setup.updater.check()).resolves.toEqual({ status: "ready", version: "0.2.10" }) + expect(setup.calls.download).toBe(1) + }) + + test("fails metadata when provider update version is invalid", async () => { + const setup = controller({ + currentVersion: () => "0.2.8", + checkForUpdates: async () => ({ + isUpdateAvailable: true, + updateInfo: { version: "not-a-version", files: [{ url: "bad.zip" }] }, + }), + }) + + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "metadata", + message: "Update version is invalid", + }) + expect(setup.calls.download).toBe(0) + }) + + test("maps electron-updater invalid version errors to metadata failure", async () => { + const error = new Error("invalid semver") + Object.assign(error, { code: "ERR_UPDATER_INVALID_VERSION" }) + const setup = controller({ + checkForUpdates: async () => { + throw error + }, + }) + + await expect(setup.updater.check()).resolves.toEqual({ + status: "failed", + reason: "metadata", + message: "invalid semver", + }) + }) + test("reports metadata failure when version is missing", async () => { const setup = controller({ checkForUpdates: async () => ({ diff --git a/packages/desktop-electron/src/main/updater.ts b/packages/desktop-electron/src/main/updater.ts index 7a910822a..c2349fa7c 100644 --- a/packages/desktop-electron/src/main/updater.ts +++ b/packages/desktop-electron/src/main/updater.ts @@ -1,3 +1,5 @@ +import { gt, parse } from "semver" + import { errorMessage } from "./error" export type UpdateResult = @@ -5,7 +7,7 @@ export type UpdateResult = | { status: "none" } | { status: "busy" } | { status: "ready"; version: string } - | { status: "failed"; reason: "check" | "download" | "metadata"; message: string } + | { status: "failed"; reason: "check" | "download" | "metadata" | "cache"; message: string } type UpdateInfo = { version?: string @@ -17,11 +19,23 @@ type Deps = { currentVersion: () => string checkForUpdates: () => Promise<{ isUpdateAvailable: boolean; updateInfo?: UpdateInfo } | null> downloadUpdate: () => Promise + clearPendingUpdate: () => Promise quitAndInstall: () => void log: (message: string, data?: Record) => void error: (message: string, error: unknown) => void } +function isInvalidVersionError(error: unknown) { + return typeof error === "object" && error !== null && "code" in error && error.code === "ERR_UPDATER_INVALID_VERSION" +} + +function newerThanCurrent(version: string, currentVersion: string) { + const parsedVersion = parse(version) + const parsedCurrent = parse(currentVersion) + if (!parsedVersion || !parsedCurrent) return "invalid" + return gt(parsedVersion, parsedCurrent) +} + export function createUpdaterController(deps: Deps) { let inflight: Promise | undefined let updateReady = false @@ -29,44 +43,81 @@ export function createUpdaterController(deps: Deps) { const run = async (): Promise => { if (!deps.enabled) return { status: "disabled" } + const currentVersion = deps.currentVersion() if (updateReady && readyVersion !== undefined) { - deps.log("update already downloaded", { releaseVersion: readyVersion }) - return { status: "ready", version: readyVersion } - } - deps.log("checking for updates", { currentVersion: deps.currentVersion() }) - - let result: Awaited> - try { - result = await deps.checkForUpdates() - } catch (error) { - deps.error("update check failed", error) - return { status: "failed", reason: "check", message: errorMessage(error) } + const comparison = newerThanCurrent(readyVersion, currentVersion) + if (comparison === "invalid") { + return { status: "failed", reason: "metadata", message: "Update version is invalid" } + } + if (comparison) { + deps.log("update already downloaded", { releaseVersion: readyVersion }) + return { status: "ready", version: readyVersion } + } + try { + await deps.clearPendingUpdate() + } catch (error) { + deps.error("stale update cache cleanup failed", error) + return { status: "failed", reason: "cache", message: errorMessage(error) } + } + updateReady = false + readyVersion = undefined } + let clearedStalePendingMetadata = false - if (!result) return { status: "none" } + while (true) { + deps.log("checking for updates", { currentVersion }) - const info = result.updateInfo - deps.log("update metadata fetched", { - releaseVersion: info?.version ?? null, - files: info?.files?.map((file) => file.url) ?? [], - }) + let result: Awaited> + try { + result = await deps.checkForUpdates() + } catch (error) { + deps.error("update check failed", error) + if (isInvalidVersionError(error)) { + return { status: "failed", reason: "metadata", message: errorMessage(error) } + } + return { status: "failed", reason: "check", message: errorMessage(error) } + } - if (!result.isUpdateAvailable) return { status: "none" } - if (!info?.version) return { status: "failed", reason: "metadata", message: "Update metadata has no version" } - if (!info.files || info.files.length === 0) { - return { status: "failed", reason: "metadata", message: "Update metadata has no files" } - } + if (!result) return { status: "none" } - try { - await deps.downloadUpdate() - } catch (error) { - deps.error("update download failed", error) - return { status: "failed", reason: "download", message: errorMessage(error) } - } + const info = result.updateInfo + deps.log("update metadata fetched", { + releaseVersion: info?.version ?? null, + files: info?.files?.map((file) => file.url) ?? [], + }) - updateReady = true - readyVersion = info.version - return { status: "ready", version: info.version } + if (!result.isUpdateAvailable) return { status: "none" } + if (!info?.version) return { status: "failed", reason: "metadata", message: "Update metadata has no version" } + if (!info.files || info.files.length === 0) { + return { status: "failed", reason: "metadata", message: "Update metadata has no files" } + } + const comparison = newerThanCurrent(info.version, currentVersion) + if (comparison === "invalid") { + return { status: "failed", reason: "metadata", message: "Update version is invalid" } + } + if (!comparison) { + if (clearedStalePendingMetadata) return { status: "none" } + try { + await deps.clearPendingUpdate() + } catch (error) { + deps.error("stale update cache cleanup failed", error) + return { status: "failed", reason: "cache", message: errorMessage(error) } + } + clearedStalePendingMetadata = true + continue + } + + try { + await deps.downloadUpdate() + } catch (error) { + deps.error("update download failed", error) + return { status: "failed", reason: "download", message: errorMessage(error) } + } + + updateReady = true + readyVersion = info.version + return { status: "ready", version: info.version } + } } return { @@ -79,6 +130,12 @@ export function createUpdaterController(deps: Deps) { }, install() { if (!updateReady || readyVersion === undefined) return false + const currentVersion = deps.currentVersion() + const comparison = newerThanCurrent(readyVersion, currentVersion) + if (comparison !== true) { + deps.log("stale ready update install skipped", { releaseVersion: readyVersion, currentVersion }) + return false + } // Keep the ready latch if quitAndInstall throws before Electron starts installing. deps.quitAndInstall() updateReady = false