From 2a497a2e56b01539d3045f75253756812189d135 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 8 Sep 2026 06:24:35 +0000 Subject: [PATCH 01/12] 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 | 205 ++++++++++++++++++++- desktop/src/main/updater.ts | 64 ++++++- 4 files changed, 272 insertions(+), 9 deletions(-) diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 801a8a90b..445e126d2 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", @@ -2644,6 +2646,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", @@ -6707,7 +6716,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 74d25ca21..901623021 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 96627619c..fa664dca7 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) @@ -28,7 +45,7 @@ vi.mock("electron", () => ({ app: { isPackaged: true, getPath: () => "/tmp/devsy-test", - getVersion: () => "1.0.0", + getVersion: () => mockAppVersion, }, dialog: { showMessageBox: vi.fn() }, })) @@ -40,6 +57,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. @@ -266,4 +287,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 6f6b579d7..2f0841a86 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" import { clearAppQuitting, markAppQuitting } from "./app-lifecycle.js" @@ -24,6 +26,41 @@ export type UpdateErrorCode = | "channel-missing" | "install-failed" +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 @@ -204,15 +241,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", @@ -338,12 +387,17 @@ 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() From ad420f62a28e04d8cf2423f6825d9ad0130514d0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 8 Sep 2026 06:33:29 +0000 Subject: [PATCH 02/12] 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 | 55 +++++-- desktop/src/main/__tests__/updater.test.ts | 10 +- desktop/src/main/tray.ts | 2 +- 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, 320 insertions(+), 100 deletions(-) diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index 3e3311cb6..2926dbf12 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -10,23 +10,41 @@ 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: "error", code: "install-failed", version: "1" }, + { state: "available", currentVersion: "1.0.0", availableVersion: "1.1.0" }, () => {}, ), - ).toHaveLength(2) + ).toEqual([]) + expect( + buildUpdateMenuItems( + { + state: "downloading", + currentVersion: "1.0.0", + availableVersion: "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" }, + onInstall, + ) expect(items).toHaveLength(2) expect(items[0]).toMatchObject({ label: "Install Update v9.9.9" }) expect(items[1]).toEqual({ type: "separator" }) @@ -37,14 +55,23 @@ 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" }) }) it("offers retry after installation fails", () => { const onInstall = vi.fn() const items = buildUpdateMenuItems( - { state: "error", code: "install-failed", version: "9.9.9" }, + { + state: "error", + currentVersion: "1.0.0", + code: "install-failed", + version: "9.9.9", + error: "install failed", + }, onInstall, ) expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" }) @@ -71,7 +98,7 @@ describe("buildTrayMenuTemplate", () => { { id: "busy", status: "busy" }, ], pendingStops: new Set(), - updateStatus: { state: "idle" }, + updateStatus: { state: "idle", currentVersion: "1.0.0" }, }, actions, ) @@ -91,7 +118,7 @@ describe("buildTrayMenuTemplate", () => { { activeWorkspaces: [], pendingStops: new Set(), - updateStatus: { state: "idle" }, + updateStatus: { state: "idle", currentVersion: "1.0.0" }, }, actions, ) @@ -104,7 +131,7 @@ describe("buildTrayMenuTemplate", () => { { activeWorkspaces: [{ id: "ws-1", status: "running" }], pendingStops: new Set(["ws-1"]), - updateStatus: { state: "idle" }, + updateStatus: { state: "idle", currentVersion: "1.0.0" }, }, actions, ) diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index fa664dca7..d098a7830 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -77,7 +77,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" }), ) }) @@ -346,7 +346,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", @@ -365,7 +365,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", @@ -427,7 +427,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", @@ -445,7 +445,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 b46d5c6ff..18d8a5255 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -18,7 +18,7 @@ export function buildUpdateMenuItems( if (status.state !== "downloaded" && !installationFailed) return [] const label = installationFailed ? `Retry Install Update v${status.version ?? ""}` - : `Install Update v${status.version ?? ""}` + : `Install Update v${status.availableVersion ?? status.version ?? ""}` return [ { label, click: onInstall }, { type: "separator" }, diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 2f0841a86..deea4345a 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -14,6 +14,7 @@ export type UpdateStateValue = | "available" | "downloading" | "downloaded" + | "up-to-date" | "not-available" | "error" @@ -67,16 +68,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 @@ -113,10 +154,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 const statusListeners = new Set<(status: UpdateStatus) => void>() @@ -224,7 +273,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 } @@ -233,6 +286,7 @@ export async function initAutoUpdater( if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { setStatus({ state: "error", + currentVersion: getCurrentVersion(), code: "unsupported", error: "Updates require a packaged build", }) @@ -242,21 +296,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 @@ -265,6 +325,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), @@ -273,15 +335,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, @@ -292,10 +366,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), }) @@ -306,7 +388,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) @@ -314,6 +397,7 @@ export async function initAutoUpdater( } setStatus({ state: "error", + currentVersion: getCurrentVersion(), code, error: err.message, }) @@ -349,13 +433,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", }) @@ -393,8 +482,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"} + {:else if s.code === "not-eligible"} +
+

A newer update is not available for this device yet.

+ {#if lastChecked} +

Last checked at {fmtTime(lastChecked)}

+ {/if} + +
{:else}

Devsy is up to date.

diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte index 4ed9dbc87..3452ffee5 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -175,6 +175,17 @@ onMount(async () => { Last checked at {fmtTime(lastChecked)} {/if}
+ {:else if s.code === "not-eligible"} +

A newer update is not available for this device yet

+
+ {#if installedVersion} + Installed: v{installedVersion} + {/if} + {channelLabel(releaseChannel)} channel + {#if lastChecked} + Last checked at {fmtTime(lastChecked)} + {/if} +
{:else}

Devsy is up to date

diff --git a/desktop/src/renderer/src/lib/components/update/status-copy.test.ts b/desktop/src/renderer/src/lib/components/update/status-copy.test.ts index 2d558939e..4ab40fff3 100644 --- a/desktop/src/renderer/src/lib/components/update/status-copy.test.ts +++ b/desktop/src/renderer/src/lib/components/update/status-copy.test.ts @@ -67,5 +67,11 @@ describe("statusHeadline", () => { "1.2.3", ), ).toBe("No releases on this channel yet") + expect( + statusHeadline( + { state: "not-available", currentVersion: "1.2.3", code: "not-eligible" }, + "1.2.3", + ), + ).toBe("A newer update is not available for this device yet") }) }) diff --git a/desktop/src/renderer/src/lib/components/update/status-copy.ts b/desktop/src/renderer/src/lib/components/update/status-copy.ts index 483f88e0c..9c6e2e66c 100644 --- a/desktop/src/renderer/src/lib/components/update/status-copy.ts +++ b/desktop/src/renderer/src/lib/components/update/status-copy.ts @@ -28,6 +28,7 @@ export function statusHeadline(s: UpdateStatus, currentVersion: string | null): case "not-available": { if (s.code === "dev-mode") return "Updates run in packaged builds" if (s.code === "channel-missing") return "No releases on this channel yet" + if (s.code === "not-eligible") return "A newer update is not available for this device yet" const current = s.currentVersion || currentVersion return current ? `Devsy is up to date · v${current}` : "Devsy is up to date" } diff --git a/desktop/src/renderer/src/lib/components/update/update-toasts.test.ts b/desktop/src/renderer/src/lib/components/update/update-toasts.test.ts index 83430d883..c11eef088 100644 --- a/desktop/src/renderer/src/lib/components/update/update-toasts.test.ts +++ b/desktop/src/renderer/src/lib/components/update/update-toasts.test.ts @@ -168,6 +168,20 @@ describe("update-toasts", () => { expect(toastFns.success).not.toHaveBeenCalled() }) + it("does not claim to be up to date for an ineligible newer update", async () => { + const { initUpdateToasts, markUserInitiated } = await import("./update-toasts.js") + initUpdateToasts(() => true) + const emit = listeners[0] + + markUserInitiated() + emit({ state: "not-available", currentVersion: "1.0.0", code: "not-eligible" }) + + expect(toastFns.info).toHaveBeenCalledWith( + "A newer update is not available for this device yet.", + ) + expect(toastFns.success).not.toHaveBeenCalled() + }) + it("fires downloaded toast with Restart action", async () => { const { initUpdateToasts } = await import("./update-toasts.js") initUpdateToasts(() => true) diff --git a/desktop/src/renderer/src/lib/components/update/update-toasts.ts b/desktop/src/renderer/src/lib/components/update/update-toasts.ts index cce7f2e63..d3c8533a8 100644 --- a/desktop/src/renderer/src/lib/components/update/update-toasts.ts +++ b/desktop/src/renderer/src/lib/components/update/update-toasts.ts @@ -77,6 +77,8 @@ function fireNotAvailable( toast.info("No releases are available on this channel yet.") } else if (s.code === "dev-mode") { toast.info("Updates run in packaged builds.") + } else if (s.code === "not-eligible") { + toast.info("A newer update is not available for this device yet.") } else { toast.success("Devsy is up to date.") } diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index 8438bbbc2..381482e80 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -28,6 +28,7 @@ export type UpdateErrorCode = | "feed-error" | "verification" | "channel-missing" + | "not-eligible" | "install-failed" export interface UpdateProgress { @@ -77,6 +78,7 @@ export type UpdateStatus = | { state: "error" currentVersion: string + availableVersion?: string code: UpdateErrorCode error: string }