From 25d0a5b402be1622bcfb9e8ac140b02f194863da Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 8 Sep 2026 06:24:35 +0000 Subject: [PATCH 1/9] fix(desktop): reject stale and downgrade updates - add semver candidate classification policy - explicitly enforce allowDowngrade = false across channel configurations - reject update-available candidates that are not newer than installed version - guard downloadUpdate and installUpdate transitions - add regression tests for #1187 and channel switches --- desktop/package-lock.json | 10 +- desktop/package.json | 2 + desktop/src/main/__tests__/updater.test.ts | 212 ++++++++++++++++++++- desktop/src/main/updater.ts | 65 ++++++- 4 files changed, 279 insertions(+), 10 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 44edc6f0f..e777be55a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -14,6 +14,7 @@ "electron-updater": "^6.8.3", "node-pty": "^1.0.0", "posthog-node": "^5.34.2", + "semver": "^7.8.5", "svelte-spa-router": "^5.0.1", "unique-names-generator": "^4.7.1" }, @@ -27,6 +28,7 @@ "@testing-library/jest-dom": "7.0.1", "@testing-library/svelte": "5.4.2", "@types/dompurify": "3.2.0", + "@types/semver": "^7.8.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", "bits-ui": "2.19.0", @@ -2654,6 +2656,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6704,7 +6713,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/desktop/package.json b/desktop/package.json index 7cadf725f..af6ddacd5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -26,6 +26,7 @@ "electron-updater": "^6.8.3", "node-pty": "^1.0.0", "posthog-node": "^5.34.2", + "semver": "^7.8.5", "svelte-spa-router": "^5.0.1", "unique-names-generator": "^4.7.1" }, @@ -39,6 +40,7 @@ "@testing-library/jest-dom": "7.0.1", "@testing-library/svelte": "5.4.2", "@types/dompurify": "3.2.0", + "@types/semver": "^7.8.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", "bits-ui": "2.19.0", diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index 2698bab4b..3c8196e08 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -1,11 +1,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest" +let mockAppVersion = "1.0.0" + const electronUpdaterMock = { autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true, - allowPrerelease: false, - channel: "latest", + _allowPrerelease: false, + get allowPrerelease() { + return this._allowPrerelease + }, + set allowPrerelease(v: boolean) { + this._allowPrerelease = v + if (v) this.allowDowngrade = true + }, + _channel: "latest", + get channel() { + return this._channel + }, + set channel(v: string) { + this._channel = v + this.allowDowngrade = true + }, + allowDowngrade: false, handlers: new Map void>(), on(event: string, cb: (...args: unknown[]) => void) { this.handlers.set(event, cb) @@ -29,7 +46,7 @@ vi.mock("electron", () => ({ isPackaged: true, isQuitting: false, getPath: () => "/tmp/devsy-test", - getVersion: () => "1.0.0", + getVersion: () => mockAppVersion, }, dialog: { showMessageBox: vi.fn() }, })) @@ -41,6 +58,10 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.checkForUpdates.mockClear() electronUpdaterMock.autoUpdater.downloadUpdate.mockClear() electronUpdaterMock.autoUpdater.quitAndInstall.mockReset() + electronUpdaterMock.autoUpdater.allowDowngrade = false + electronUpdaterMock.autoUpdater._allowPrerelease = false + electronUpdaterMock.autoUpdater._channel = "latest" + mockAppVersion = "1.0.0" vi.resetModules() // Restore isPackaged on every test so an early throw in one test // cannot silently flip later tests into the dev-mode branch. @@ -107,13 +128,18 @@ describe("updater", () => { ;( electron.app as typeof electron.app & { isQuitting?: boolean } ).isQuitting = false + const { initAutoUpdater, installUpdate } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "2.0.0" }) + let quittingWhenInstalled: boolean | undefined electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => { quittingWhenInstalled = ( electron.app as typeof electron.app & { isQuitting?: boolean } ).isQuitting }) - const { installUpdate } = await import("../updater.js") await installUpdate() expect(quittingWhenInstalled).toBe(true) expect( @@ -229,4 +255,182 @@ describe("updater", () => { vi.useRealTimers() } }) + + it("guards downloadUpdate so it only runs when an update is available and newer", async () => { + const { initAutoUpdater, downloadUpdate } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + // Initially idle + await downloadUpdate() + expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() + + // Available with a newer version + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.1.0" }) + await downloadUpdate() + expect(electronUpdaterMock.autoUpdater.downloadUpdate).toHaveBeenCalledTimes(1) + }) + + it("guards installUpdate so it only runs when status is downloaded", async () => { + const { initAutoUpdater, installUpdate } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + // State is idle + await installUpdate() + expect(electronUpdaterMock.autoUpdater.quitAndInstall).not.toHaveBeenCalled() + }) + + describe("classifyCandidate", () => { + it("classifies newer, same, older, and invalid correctly", async () => { + const { classifyCandidate } = await import("../updater.js") + expect(classifyCandidate("1.17.0", "1.18.0")).toEqual({ kind: "newer", version: "1.18.0" }) + expect(classifyCandidate("1.17.0", "1.17.1")).toEqual({ kind: "newer", version: "1.17.1" }) + expect(classifyCandidate("1.17.0", "1.17.0")).toEqual({ kind: "same", version: "1.17.0" }) + expect(classifyCandidate("1.17.0", "1.16.2")).toEqual({ kind: "older", version: "1.16.2" }) + expect(classifyCandidate("1.18.0-beta.2", "1.18.0-beta.3")).toEqual({ + kind: "newer", + version: "1.18.0-beta.3", + }) + expect(classifyCandidate("1.18.0-beta.2", "1.17.0")).toEqual({ + kind: "older", + version: "1.17.0", + }) + expect(classifyCandidate("1.17.0", "garbage")).toEqual({ kind: "invalid", version: "garbage" }) + expect(classifyCandidate("garbage", "1.17.0")).toEqual({ kind: "invalid", version: "1.17.0" }) + }) + }) + + describe("candidate validation and #1187 regression", () => { + it("rejects an older candidate (1.17.0 vs 1.16.2) and reports not-available (#1187)", async () => { + mockAppVersion = "1.17.0" + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.16.2" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "not-available" }), + ) + expect(send).not.toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available" }), + ) + expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() + }) + + it("treats equal version (1.17.0 vs 1.17.0) as not-available", async () => { + mockAppVersion = "1.17.0" + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.17.0" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "not-available" }), + ) + expect(send).not.toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available" }), + ) + }) + + it("accepts a newer minor version (1.17.0 vs 1.18.0) as available", async () => { + mockAppVersion = "1.17.0" + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.18.0" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available", version: "1.18.0" }), + ) + }) + + it("accepts a newer patch version (1.17.0 vs 1.17.1) as available", async () => { + mockAppVersion = "1.17.0" + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.17.1" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available", version: "1.17.1" }), + ) + }) + + it("accepts preview progression (1.18.0-beta.2 vs 1.18.0-beta.3) as available", async () => { + mockAppVersion = "1.18.0-beta.2" + const { initAutoUpdater, checkForUpdatesWithChannel } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + await checkForUpdatesWithChannel("beta") + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.18.0-beta.3" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available", version: "1.18.0-beta.3" }), + ) + }) + + it("rejects older stable feed after preview switch (1.18.0-beta.2 vs 1.17.0 on stable)", async () => { + mockAppVersion = "1.18.0-beta.2" + const { initAutoUpdater, checkForUpdatesWithChannel } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + await checkForUpdatesWithChannel("stable") + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.17.0" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "not-available" }), + ) + expect(send).not.toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available" }), + ) + }) + + it("safely handles malformed candidate versions", async () => { + mockAppVersion = "1.17.0" + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "not-a-version" }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "not-available" }), + ) + expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() + }) + + it("enforces allowDowngrade is false across channel configurations", async () => { + const { initAutoUpdater, checkForUpdatesWithChannel } = await import("../updater.js") + const win = { isDestroyed: () => false, webContents: { send: vi.fn() } } as never + await initAutoUpdater(() => win) + expect(electronUpdaterMock.autoUpdater.allowDowngrade).toBe(false) + + await checkForUpdatesWithChannel("beta") + expect(electronUpdaterMock.autoUpdater.allowPrerelease).toBe(true) + expect(electronUpdaterMock.autoUpdater.allowDowngrade).toBe(false) + + await checkForUpdatesWithChannel("stable") + expect(electronUpdaterMock.autoUpdater.allowPrerelease).toBe(false) + expect(electronUpdaterMock.autoUpdater.allowDowngrade).toBe(false) + }) + }) }) diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index b0e137858..3381d99c2 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -1,6 +1,8 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs" import { join } from "node:path" import { app, type BrowserWindow } from "electron" +import type { AppUpdater } from "electron-updater" +import semver from "semver" import { trackEvent } from "./analytics.js" export type ReleaseChannel = "stable" | "beta" @@ -22,6 +24,41 @@ export type UpdateErrorCode = | "verification" | "channel-missing" +export type CandidateResult = + | { kind: "newer"; version: string } + | { kind: "same"; version: string } + | { kind: "older"; version: string } + | { kind: "invalid"; version: string } + +export function classifyCandidate( + currentVersion: string, + candidateVersion: string, +): CandidateResult { + const current = semver.clean(currentVersion) ?? semver.valid(currentVersion) + const candidate = semver.clean(candidateVersion) ?? semver.valid(candidateVersion) + + if (!current || !candidate) { + return { kind: "invalid", version: candidateVersion } + } + + const diff = semver.compare(candidate, current) + if (diff > 0) { + return { kind: "newer", version: candidate } + } + if (diff === 0) { + return { kind: "same", version: candidate } + } + return { kind: "older", version: candidate } +} + +export function configureUpdaterChannel( + autoUpdater: AppUpdater, + channel: ReleaseChannel, +): void { + autoUpdater.allowPrerelease = channel === "beta" + autoUpdater.channel = channel === "beta" ? "beta" : "latest" + autoUpdater.allowDowngrade = false +} export interface UpdateProgress { percent: number bytesPerSecond: number @@ -187,15 +224,27 @@ export async function initAutoUpdater( autoUpdater.autoDownload = autoDownloadEnabled autoUpdater.autoInstallOnAppQuit = true - autoUpdater.allowPrerelease = currentChannel === "beta" - autoUpdater.channel = currentChannel === "beta" ? "beta" : "latest" - + configureUpdaterChannel(autoUpdater, currentChannel) autoUpdater.on("checking-for-update", () => { trackEvent("update_check") setStatus({ state: "checking" }) }) autoUpdater.on("update-available", (info) => { + const currentVersion = app.getVersion() + const candidate = classifyCandidate(currentVersion, info.version) + + if (candidate.kind !== "newer") { + console.warn( + `[updater] candidate ${info.version} is not newer than installed ${currentVersion} (${candidate.kind}); treating as not available`, + ) + setStatus({ + state: "not-available", + version: info.version, + }) + return + } + trackEvent("update_available", { version: info.version }) setStatus({ state: "available", @@ -321,18 +370,24 @@ export async function checkForUpdatesWithChannel(channel: ReleaseChannel): Promi currentChannel = channel const autoUpdater = await getUpdater() if (!autoUpdater) return - autoUpdater.allowPrerelease = channel === "beta" - autoUpdater.channel = channel === "beta" ? "beta" : "latest" + configureUpdaterChannel(autoUpdater, channel) await runUpdateCheck(autoUpdater) } export async function downloadUpdate(): Promise { + if (lastStatus.state !== "available") return + const currentVersion = app.getVersion() + const candidateVersion = lastStatus.version ?? "" + if (classifyCandidate(currentVersion, candidateVersion).kind !== "newer") { + return + } const autoUpdater = await getUpdater() if (!autoUpdater) return await autoUpdater.downloadUpdate() } export async function installUpdate(): Promise { + if (lastStatus.state !== "downloaded") return const autoUpdater = await getUpdater() if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") return ;(app as typeof app & { isQuitting?: boolean }).isQuitting = true From fa9a2f98fc348ac58bbba420ee223049699c77df Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 8 Sep 2026 06:33:29 +0000 Subject: [PATCH 2/9] refactor(desktop): make update version state explicit - distinguish currentVersion and availableVersion in UpdateStatus - use up-to-date state across updater and renderer - preserve candidate version across download lifecycle - update IPC types and renderer stores for explicit version state --- desktop/src/main/__tests__/tray.test.ts | 44 +++++- desktop/src/main/__tests__/updater.test.ts | 10 +- desktop/src/main/tray.ts | 3 +- desktop/src/main/updater.ts | 137 +++++++++++++++--- .../lib/components/update/UpdateBadge.svelte | 15 +- .../lib/components/update/UpdateDialog.svelte | 4 +- .../components/update/UpdateDialog.test.ts | 14 +- .../lib/components/update/UpdatesPanel.svelte | 6 +- .../components/update/UpdatesPanel.test.ts | 2 +- .../lib/components/update/status-copy.test.ts | 44 ++++-- .../src/lib/components/update/status-copy.ts | 25 +++- .../lib/components/update/update-toasts.ts | 33 +++-- desktop/src/renderer/src/lib/ipc/events.ts | 60 ++++++-- .../renderer/src/lib/stores/updates.svelte.ts | 13 +- 14 files changed, 316 insertions(+), 94 deletions(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index ae571c474..724e25d10 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -9,17 +9,42 @@ vi.mock("../updater.js", () => ({ describe("buildUpdateMenuItems", () => { it("returns nothing when no update is downloaded", () => { - expect(buildUpdateMenuItems({ state: "idle" }, () => {})).toEqual([]) - expect(buildUpdateMenuItems({ state: "checking" }, () => {})).toEqual([]) - expect(buildUpdateMenuItems({ state: "available", version: "1" }, () => {})).toEqual([]) - expect(buildUpdateMenuItems({ state: "downloading", version: "1" }, () => {})).toEqual([]) - expect(buildUpdateMenuItems({ state: "not-available" }, () => {})).toEqual([]) - expect(buildUpdateMenuItems({ state: "error", error: "x" }, () => {})).toEqual([]) + expect(buildUpdateMenuItems({ state: "idle", currentVersion: "1.0.0" }, () => {})).toEqual([]) + expect(buildUpdateMenuItems({ state: "checking", currentVersion: "1.0.0" }, () => {})).toEqual([]) + expect( + buildUpdateMenuItems( + { state: "available", currentVersion: "1.0.0", availableVersion: "1.1.0", version: "1.1.0" }, + () => {}, + ), + ).toEqual([]) + expect( + buildUpdateMenuItems( + { + state: "downloading", + currentVersion: "1.0.0", + availableVersion: "1.1.0", + version: "1.1.0", + progress: { percent: 50, bytesPerSecond: 1000, transferred: 50, total: 100 }, + }, + () => {}, + ), + ).toEqual([]) + expect(buildUpdateMenuItems({ state: "not-available", currentVersion: "1.0.0" }, () => {})).toEqual([]) + expect(buildUpdateMenuItems({ state: "up-to-date", currentVersion: "1.0.0" }, () => {})).toEqual([]) + expect( + buildUpdateMenuItems( + { state: "error", currentVersion: "1.0.0", error: "x", code: "network" }, + () => {}, + ), + ).toEqual([]) }) it("adds Install Update item + separator when downloaded", () => { const onInstall = vi.fn() - const items = buildUpdateMenuItems({ state: "downloaded", version: "9.9.9" }, onInstall) + const items = buildUpdateMenuItems( + { state: "downloaded", currentVersion: "1.0.0", availableVersion: "9.9.9", version: "9.9.9" }, + onInstall, + ) expect(items).toHaveLength(2) expect(items[0]).toMatchObject({ label: "Install Update v9.9.9" }) expect(items[1]).toEqual({ type: "separator" }) @@ -30,7 +55,10 @@ describe("buildUpdateMenuItems", () => { }) it("handles missing version gracefully", () => { - const items = buildUpdateMenuItems({ state: "downloaded" }, () => {}) + const items = buildUpdateMenuItems( + { state: "downloaded", currentVersion: "1.0.0", availableVersion: "" }, + () => {}, + ) expect(items[0]).toMatchObject({ label: "Install Update v" }) }) }) diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index 3c8196e08..641330a4f 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -78,7 +78,7 @@ describe("updater", () => { await initAutoUpdater(() => win) expect(send).toHaveBeenCalledWith( "update-status", - expect.objectContaining({ state: "not-available", code: "dev-mode" }), + expect.objectContaining({ state: "up-to-date", code: "dev-mode" }), ) }) @@ -314,7 +314,7 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.16.2" }) expect(send).toHaveBeenCalledWith( "update-status", - expect.objectContaining({ state: "not-available" }), + expect.objectContaining({ state: "up-to-date" }), ) expect(send).not.toHaveBeenCalledWith( "update-status", @@ -333,7 +333,7 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.17.0" }) expect(send).toHaveBeenCalledWith( "update-status", - expect.objectContaining({ state: "not-available" }), + expect.objectContaining({ state: "up-to-date" }), ) expect(send).not.toHaveBeenCalledWith( "update-status", @@ -395,7 +395,7 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.17.0" }) expect(send).toHaveBeenCalledWith( "update-status", - expect.objectContaining({ state: "not-available" }), + expect.objectContaining({ state: "up-to-date" }), ) expect(send).not.toHaveBeenCalledWith( "update-status", @@ -413,7 +413,7 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.emit("update-available", { version: "not-a-version" }) expect(send).toHaveBeenCalledWith( "update-status", - expect.objectContaining({ state: "not-available" }), + expect.objectContaining({ state: "up-to-date" }), ) expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() }) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 859c5eae1..2c42e5aa2 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -13,8 +13,9 @@ export function buildUpdateMenuItems( onInstall: () => void, ): Electron.MenuItemConstructorOptions[] { if (status.state !== "downloaded") return [] + const version = status.availableVersion ?? status.version ?? "" return [ - { label: `Install Update v${status.version ?? ""}`, click: onInstall }, + { label: `Install Update v${version}`, click: onInstall }, { type: "separator" }, ] } diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 3381d99c2..14b551ea8 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -13,6 +13,7 @@ export type UpdateStateValue = | "available" | "downloading" | "downloaded" + | "up-to-date" | "not-available" | "error" @@ -65,16 +66,56 @@ export interface UpdateProgress { transferred: number total: number } - -export interface UpdateStatus { - state: UpdateStateValue - version?: string - releaseNotes?: string - releaseName?: string - progress?: UpdateProgress - error?: string - code?: UpdateErrorCode -} +export type UpdateStatus = + | { + state: "idle" + currentVersion: string + version?: string + } + | { + state: "checking" + currentVersion: string + version?: string + } + | { + state: "up-to-date" | "not-available" + currentVersion: string + version?: string + lastCheckedAt?: number + feedVersion?: string + code?: UpdateErrorCode + } + | { + state: "available" + currentVersion: string + availableVersion: string + version?: string + releaseNotes?: string + releaseName?: string + code?: UpdateErrorCode + } + | { + state: "downloading" + currentVersion: string + availableVersion: string + version?: string + progress: UpdateProgress + } + | { + state: "downloaded" + currentVersion: string + availableVersion: string + version?: string + releaseNotes?: string + releaseName?: string + } + | { + state: "error" + currentVersion: string + version?: string + code: UpdateErrorCode + error: string + } interface PersistedSettings { channel?: ReleaseChannel @@ -111,10 +152,18 @@ function saveSettings(patch: PersistedSettings): void { const INITIAL_CHECK_DELAY_MS = 10_000 const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 +function getCurrentVersion(): string { + try { + return app.getVersion() + } catch { + return "" + } +} + let currentChannel: ReleaseChannel = "stable" let autoDownloadEnabled = true let getMainWindowFn: (() => BrowserWindow | null) | null = null -let lastStatus: UpdateStatus = { state: "idle" } +let lastStatus: UpdateStatus = { state: "idle", currentVersion: "" } let initialCheckTimer: ReturnType | null = null let recheckTimer: ReturnType | null = null @@ -207,7 +256,11 @@ export async function initAutoUpdater( autoDownloadEnabled = settings.autoDownload ?? true if (!app.isPackaged) { - setStatus({ state: "not-available", code: "dev-mode" }) + setStatus({ + state: "up-to-date", + currentVersion: getCurrentVersion(), + code: "dev-mode", + }) return } @@ -216,6 +269,7 @@ export async function initAutoUpdater( if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { setStatus({ state: "error", + currentVersion: getCurrentVersion(), code: "unsupported", error: "Updates require a packaged build", }) @@ -225,21 +279,27 @@ export async function initAutoUpdater( autoUpdater.autoDownload = autoDownloadEnabled autoUpdater.autoInstallOnAppQuit = true configureUpdaterChannel(autoUpdater, currentChannel) + autoUpdater.on("checking-for-update", () => { trackEvent("update_check") - setStatus({ state: "checking" }) + setStatus({ + state: "checking", + currentVersion: getCurrentVersion(), + }) }) autoUpdater.on("update-available", (info) => { - const currentVersion = app.getVersion() + const currentVersion = getCurrentVersion() const candidate = classifyCandidate(currentVersion, info.version) if (candidate.kind !== "newer") { console.warn( - `[updater] candidate ${info.version} is not newer than installed ${currentVersion} (${candidate.kind}); treating as not available`, + `[updater] candidate ${info.version} is not newer than installed ${currentVersion} (${candidate.kind}); treating as up to date`, ) setStatus({ - state: "not-available", + state: "up-to-date", + currentVersion, + feedVersion: info.version, version: info.version, }) return @@ -248,6 +308,8 @@ export async function initAutoUpdater( trackEvent("update_available", { version: info.version }) setStatus({ state: "available", + currentVersion, + availableVersion: info.version, version: info.version, releaseName: info.releaseName ?? undefined, releaseNotes: normalizeReleaseNotes(info.releaseNotes), @@ -256,15 +318,27 @@ export async function initAutoUpdater( autoUpdater.on("update-not-available", (info) => { setStatus({ - state: "not-available", + state: "up-to-date", + currentVersion: getCurrentVersion(), + feedVersion: info.version, version: info.version, }) }) autoUpdater.on("download-progress", (info) => { + const availableVersion = + (lastStatus.state === "available" || + lastStatus.state === "downloading" || + lastStatus.state === "downloaded" + ? lastStatus.availableVersion + : undefined) ?? + lastStatus.version ?? + "" setStatus({ - ...lastStatus, state: "downloading", + currentVersion: getCurrentVersion(), + availableVersion, + version: availableVersion, progress: { percent: info.percent, bytesPerSecond: info.bytesPerSecond, @@ -275,10 +349,18 @@ export async function initAutoUpdater( }) autoUpdater.on("update-downloaded", (info) => { - trackEvent("update_downloaded", { version: info.version }) + const availableVersion = + (lastStatus.state === "available" || + lastStatus.state === "downloading" || + lastStatus.state === "downloaded" + ? lastStatus.availableVersion + : undefined) ?? info.version + trackEvent("update_downloaded", { version: availableVersion }) setStatus({ state: "downloaded", - version: info.version, + currentVersion: getCurrentVersion(), + availableVersion, + version: availableVersion, releaseName: info.releaseName ?? undefined, releaseNotes: normalizeReleaseNotes(info.releaseNotes), }) @@ -289,7 +371,8 @@ export async function initAutoUpdater( trackEvent("update_error", { error_type: err.name }) if (code === "channel-missing") { setStatus({ - state: "not-available", + state: "up-to-date", + currentVersion: getCurrentVersion(), code, }) console.warn("Auto-update: channel manifest missing:", err.message) @@ -297,6 +380,7 @@ export async function initAutoUpdater( } setStatus({ state: "error", + currentVersion: getCurrentVersion(), code, error: err.message, }) @@ -332,13 +416,18 @@ export function stopAutoUpdater(): void { async function getUpdater() { if (!app.isPackaged) { - setStatus({ state: "not-available", code: "dev-mode" }) + setStatus({ + state: "up-to-date", + currentVersion: getCurrentVersion(), + code: "dev-mode", + }) return null } const autoUpdater = await loadAutoUpdater() if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { setStatus({ state: "error", + currentVersion: getCurrentVersion(), code: "unsupported", error: "Updates require a packaged build", }) @@ -376,8 +465,8 @@ export async function checkForUpdatesWithChannel(channel: ReleaseChannel): Promi export async function downloadUpdate(): Promise { if (lastStatus.state !== "available") return - const currentVersion = app.getVersion() - const candidateVersion = lastStatus.version ?? "" + const currentVersion = getCurrentVersion() + const candidateVersion = lastStatus.availableVersion ?? lastStatus.version ?? "" if (classifyCandidate(currentVersion, candidateVersion).kind !== "newer") { return } diff --git a/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte b/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte index 5185d6bfa..e386d396e 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte @@ -9,8 +9,13 @@ const show = $derived(hasUpdate()) const ready = $derived(isReady()) const downloading = $derived(s.state === "downloading") + const version = $derived( + s.state === "available" || s.state === "downloading" || s.state === "downloaded" + ? (s.availableVersion ?? s.version ?? "") + : "", + ) + const percent = $derived(s.state === "downloading" ? s.progress.percent : 0) - {#if show} + {/if} {:else if s.state === "downloading"}
-

Downloading v{s.version}…

- +

Downloading v{s.availableVersion ?? s.version}…

+

- {(s.progress?.percent ?? 0).toFixed(0)}% · {fmtMBps(s.progress?.bytesPerSecond)} + {s.progress.percent.toFixed(0)}% · {fmtMBps(s.progress.bytesPerSecond)}

{:else if s.state === "downloaded"}

- Version {s.version} is ready to install. + Version {s.availableVersion ?? s.version} is ready to install.

- {#if s.releaseNotes} + {#if sanitizedNotes}
{@html sanitizedNotes}
{/if}
- +
- {:else if s.state === "not-available"} + {:else if s.state === "up-to-date" || s.state === "not-available"} {#if s.code === "dev-mode"}

Updates are available in packaged builds.

{:else if s.code === "channel-missing"} @@ -105,7 +105,7 @@ async function onInstall() { {:else}
-

You're on the latest version.

+

Devsy is up to date.

{#if lastChecked}

Last checked at {fmtTime(lastChecked)}

{/if} @@ -116,9 +116,9 @@ async function onInstall() { {/if} {:else if s.state === "error"}
-

Update check failed: {s.error}

+

Couldn't check for updates: {s.error}

{:else} diff --git a/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts b/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts index c4d1ed300..9bd9cdd96 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts +++ b/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts @@ -79,7 +79,7 @@ describe("UpdateDialog", () => { }) render(UpdateDialog, { props: { open: true } }) expect(bodyText()).toMatch(/404 from cdn/i) - expect(queryButton(/check again/i)).toBeTruthy() + expect(queryButton(/try again|check again/i)).toBeTruthy() }) it("renders dev-mode hint in not-available + dev-mode", () => { diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte index 23322e583..da0dcdab6 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -45,6 +45,7 @@ const sanitizedNotes = $derived( : "", ) const headline = $derived(statusHeadline(s, appVersion)) +const installedVersion = $derived(s.currentVersion || appVersion || "") async function loadVersion(): Promise { try { @@ -113,22 +114,58 @@ onMount(async () => { {:else if s.state === "error"} {:else} - + {/if}
-

{headline}

- - {#if s.state === "error"} -

{s.error}

+ {#if s.state === "checking"} +

Checking for updates…

+
+ {#if installedVersion} + Installed: v{installedVersion} + {/if} + {channelLabel(releaseChannel)} channel +
+ {:else if s.state === "available"} +

Devsy {s.availableVersion} is available

+
+
+ Installed: + v{installedVersion || "unknown"} +
+
+ Available: + v{s.availableVersion} +
+
+ Channel: + {channelLabel(releaseChannel)} +
+
{:else if s.state === "downloading"} - +

Downloading Devsy {s.availableVersion}

+

- {(s.progress?.percent ?? 0).toFixed(0)}% · {fmtMBps(s.progress?.bytesPerSecond)} + {s.progress.percent.toFixed(0)}% · {fmtMBps(s.progress.bytesPerSecond)}

- {:else if lastChecked && (s.state === "not-available" || s.state === "idle")} -

Last checked at {fmtTime(lastChecked)}

+ {:else if s.state === "downloaded"} +

Devsy {s.availableVersion} is ready

+

Restart Devsy to finish updating.

+ {:else if s.state === "error"} +

Couldn't check for updates

+

{s.error}

+ {:else} +

Devsy is up to date

+
+ {#if installedVersion} + Version {installedVersion} + {/if} + {channelLabel(releaseChannel)} channel + {#if lastChecked} + Last checked at {fmtTime(lastChecked)} + {/if} +
{/if} {#if (s.state === "available" || s.state === "downloaded") && sanitizedNotes} @@ -140,9 +177,20 @@ onMount(async () => {
{#if s.state === "available"} - + {:else if s.state === "downloaded"} - + + {:else if s.state === "error"} + {:else} - +
{:else if s.state === "up-to-date" || s.state === "not-available"} diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte index da0dcdab6..f40299af8 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -179,7 +179,7 @@ onMount(async () => { {#if s.state === "available"} {:else if s.state === "downloaded"} - + {:else if s.state === "error"}