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__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index ae571c474..d9e651208 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -9,19 +9,44 @@ 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", () => { + it("adds 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[0]).toMatchObject({ label: "Update to 9.9.9" }) expect(items[1]).toEqual({ type: "separator" }) const click = (items[0] as { click?: () => void }).click @@ -30,7 +55,10 @@ describe("buildUpdateMenuItems", () => { }) it("handles missing version gracefully", () => { - const items = buildUpdateMenuItems({ state: "downloaded" }, () => {}) - expect(items[0]).toMatchObject({ label: "Install Update v" }) + const items = buildUpdateMenuItems( + { state: "downloaded", currentVersion: "1.0.0", availableVersion: "" }, + () => {}, + ) + expect(items[0]).toMatchObject({ label: "Restart" }) }) }) diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index 2698bab4b..8604b7bf2 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" +let mockAllowPrerelease = false +let mockChannel = "latest" + const electronUpdaterMock = { autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true, - allowPrerelease: false, - channel: "latest", + get allowPrerelease() { + return mockAllowPrerelease + }, + set allowPrerelease(v: boolean) { + mockAllowPrerelease = v + if (v) this.allowDowngrade = true + }, + get channel() { + return mockChannel + }, + set channel(v: string) { + mockChannel = 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 + mockAllowPrerelease = false + mockChannel = "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. @@ -57,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" }), ) }) @@ -66,6 +87,7 @@ describe("updater", () => { const send = vi.fn() const win = { isDestroyed: () => false, webContents: { send } } as never await initAutoUpdater(() => win) + electronUpdaterMock.autoUpdater.emit("update-available", { version: "2.0.0" }) electronUpdaterMock.autoUpdater.emit("download-progress", { percent: 42, bytesPerSecond: 1000, @@ -107,20 +129,25 @@ 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-available", { version: "2.0.0" }) + 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( electronUpdaterMock.autoUpdater.quitAndInstall, ).toHaveBeenCalledTimes(1) }) - it("swallows a channel-missing rejection from check_for_updates", async () => { electronUpdaterMock.autoUpdater.checkForUpdates.mockRejectedValueOnce( new Error( @@ -145,6 +172,7 @@ describe("updater", () => { const send = vi.fn() const win = { isDestroyed: () => false, webContents: { send } } as never await initAutoUpdater(() => win) + electronUpdaterMock.autoUpdater.emit("update-available", { version: "9.9.9" }) electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "9.9.9", }) @@ -216,6 +244,7 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.checkForUpdates, ).toHaveBeenCalledTimes(1) + electronUpdaterMock.autoUpdater.emit("update-available", { version: "9.9.9" }) electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "9.9.9", }) @@ -229,4 +258,247 @@ 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: "up-to-date" }), + ) + expect(send).not.toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "available" }), + ) + expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() + }) + + it("cancels autoDownload and ignores download events for rejected candidate", 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(electronUpdaterMock.autoUpdater.autoDownload).toBe(false) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "up-to-date" }), + ) + + electronUpdaterMock.autoUpdater.emit("download-progress", { + percent: 50, + bytesPerSecond: 1000, + transferred: 50, + total: 100, + }) + electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "1.16.2" }) + + expect(send).not.toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "downloading" }), + ) + expect(send).not.toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "downloaded" }), + ) + }) + + 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: "up-to-date" }), + ) + 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: "up-to-date" }), + ) + 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: "up-to-date" }), + ) + 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) + }) + }) + + describe("structured diagnostics logging", () => { + it("logs check result with current, feed, channel, and result fields", async () => { + mockAppVersion = "1.17.0" + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}) + const { initAutoUpdater } = await import("../updater.js") + const win = { isDestroyed: () => false, webContents: { send: vi.fn() } } as never + await initAutoUpdater(() => win) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.16.2" }) + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /\[updater\] check result: current=1\.17\.0 feed=1\.16\.2 channel=stable result=feed-behind/, + ), + ) + + electronUpdaterMock.autoUpdater.emit("update-available", { version: "1.18.0" }) + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /\[updater\] check result: current=1\.17\.0 feed=1\.18\.0 available=1\.18\.0 channel=stable result=newer/, + ), + ) + + electronUpdaterMock.autoUpdater.emit("update-not-available", { version: "1.17.0" }) + expect(infoSpy).toHaveBeenCalledWith( + expect.stringMatching( + /\[updater\] check result: current=1\.17\.0 feed=1\.17\.0 channel=stable result=same/, + ), + ) + + infoSpy.mockRestore() + }) + }) }) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 859c5eae1..f757cff09 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -13,8 +13,10 @@ export function buildUpdateMenuItems( onInstall: () => void, ): Electron.MenuItemConstructorOptions[] { if (status.state !== "downloaded") return [] + const version = status.availableVersion ?? status.version ?? "" + const label = version ? `Update to ${version}` : "Restart" return [ - { label: `Install Update v${status.version ?? ""}`, click: onInstall }, + { label, click: onInstall }, { type: "separator" }, ] } diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index b0e137858..46d644760 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" @@ -11,6 +13,7 @@ export type UpdateStateValue = | "available" | "downloading" | "downloaded" + | "up-to-date" | "not-available" | "error" @@ -22,22 +25,128 @@ 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 type UpdateDecisionResult = + | "newer" + | "same" + | "feed-behind" + | "invalid-version" + | "download-started" + | "downloaded" + | "error" + +export interface UpdateDecisionLog { + currentVersion: string + feedVersion?: string + availableVersion?: string + channel: ReleaseChannel + result: UpdateDecisionResult + error?: string +} + +export function logUpdateDecision(params: UpdateDecisionLog): void { + const parts = [ + `[updater] check result:`, + `current=${params.currentVersion}`, + params.feedVersion ? `feed=${params.feedVersion}` : null, + params.availableVersion ? `available=${params.availableVersion}` : null, + `channel=${params.channel}`, + `result=${params.result}`, + params.error ? `error=${params.error}` : null, + ].filter(Boolean) + console.info(parts.join(" ")) +} export interface UpdateProgress { percent: number bytesPerSecond: number 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 @@ -74,10 +183,22 @@ 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 (err) { + console.error( + "Auto-update: unable to read app version:", + err instanceof Error ? err.message : String(err), + ) + 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 @@ -170,7 +291,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 } @@ -179,6 +304,7 @@ export async function initAutoUpdater( if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { setStatus({ state: "error", + currentVersion: getCurrentVersion(), code: "unsupported", error: "Updates require a packaged build", }) @@ -187,18 +313,60 @@ 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" }) + setStatus({ + state: "checking", + currentVersion: getCurrentVersion(), + }) }) autoUpdater.on("update-available", (info) => { + const currentVersion = getCurrentVersion() + const candidate = classifyCandidate(currentVersion, info.version) + + if (candidate.kind !== "newer") { + const result: UpdateDecisionResult = + candidate.kind === "older" + ? "feed-behind" + : candidate.kind === "same" + ? "same" + : "invalid-version" + logUpdateDecision({ + currentVersion, + feedVersion: info.version, + channel: currentChannel, + result, + }) + // autoDownload is honored by electron-updater at the moment this event + // fires, so a rejected candidate would still be fetched. Cancel it. + autoUpdater.autoDownload = false + setStatus({ + state: "up-to-date", + currentVersion, + feedVersion: info.version, + version: info.version, + }) + return + } + + // Restore user preference before a legitimate candidate downloads. + autoUpdater.autoDownload = autoDownloadEnabled + + logUpdateDecision({ + currentVersion, + feedVersion: info.version, + availableVersion: info.version, + channel: currentChannel, + result: "newer", + }) trackEvent("update_available", { version: info.version }) setStatus({ state: "available", + currentVersion, + availableVersion: info.version, version: info.version, releaseName: info.releaseName ?? undefined, releaseNotes: normalizeReleaseNotes(info.releaseNotes), @@ -206,16 +374,31 @@ export async function initAutoUpdater( }) autoUpdater.on("update-not-available", (info) => { + const currentVersion = getCurrentVersion() + logUpdateDecision({ + currentVersion, + feedVersion: info.version, + channel: currentChannel, + result: "same", + }) setStatus({ - state: "not-available", + state: "up-to-date", + currentVersion, + feedVersion: info.version, version: info.version, }) }) autoUpdater.on("download-progress", (info) => { + if (lastStatus.state !== "available" && lastStatus.state !== "downloading") { + return + } + const availableVersion = lastStatus.availableVersion setStatus({ - ...lastStatus, state: "downloading", + currentVersion: getCurrentVersion(), + availableVersion, + version: availableVersion, progress: { percent: info.percent, bytesPerSecond: info.bytesPerSecond, @@ -226,21 +409,40 @@ export async function initAutoUpdater( }) autoUpdater.on("update-downloaded", (info) => { - trackEvent("update_downloaded", { version: info.version }) + if (lastStatus.state !== "available" && lastStatus.state !== "downloading") { + return + } + const availableVersion = lastStatus.availableVersion + const currentVersion = getCurrentVersion() + logUpdateDecision({ + currentVersion, + availableVersion, + channel: currentChannel, + result: "downloaded", + }) setStatus({ state: "downloaded", - version: info.version, + currentVersion, + availableVersion, + version: availableVersion, releaseName: info.releaseName ?? undefined, releaseNotes: normalizeReleaseNotes(info.releaseNotes), }) }) - autoUpdater.on("error", (err) => { const code = classifyError(err) + const currentVersion = getCurrentVersion() + logUpdateDecision({ + currentVersion, + channel: currentChannel, + result: "error", + error: err.message, + }) trackEvent("update_error", { error_type: err.name }) if (code === "channel-missing") { setStatus({ - state: "not-available", + state: "up-to-date", + currentVersion, code, }) console.warn("Auto-update: channel manifest missing:", err.message) @@ -248,6 +450,7 @@ export async function initAutoUpdater( } setStatus({ state: "error", + currentVersion, code, error: err.message, }) @@ -283,13 +486,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", }) @@ -321,18 +529,30 @@ 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 = getCurrentVersion() + const candidateVersion = lastStatus.availableVersion ?? lastStatus.version ?? "" + if (classifyCandidate(currentVersion, candidateVersion).kind !== "newer") { + return + } + logUpdateDecision({ + currentVersion, + availableVersion: candidateVersion, + channel: currentChannel, + result: "download-started", + }) 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 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}
@@ -88,7 +90,7 @@ async function onInstall() {
- {: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"} @@ -103,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} @@ -114,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 3e00a77ea..9bd9cdd96 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts +++ b/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts @@ -35,13 +35,18 @@ describe("UpdateDialog", () => { }) it("renders 'checking' state", () => { - __setForTest({ state: "checking" }) + __setForTest({ state: "checking", currentVersion: "1.0.0" }) render(UpdateDialog, { props: { open: true } }) expect(bodyText()).toMatch(/checking for updates/i) }) it("renders 'downloaded' state with restart CTA", () => { - __setForTest({ state: "downloaded", version: "9.9.9" }) + __setForTest({ + state: "downloaded", + currentVersion: "1.0.0", + availableVersion: "9.9.9", + version: "9.9.9", + }) render(UpdateDialog, { props: { open: true } }) expect(bodyText()).toMatch(/version 9\.9\.9/i) expect(queryButton(/restart/i)).toBeTruthy() @@ -50,6 +55,8 @@ describe("UpdateDialog", () => { it("renders 'downloading' progress", () => { __setForTest({ state: "downloading", + currentVersion: "1.0.0", + availableVersion: "9.9.9", version: "9.9.9", progress: { percent: 42, @@ -66,16 +73,17 @@ describe("UpdateDialog", () => { it("renders 'error' with retry", () => { __setForTest({ state: "error", + currentVersion: "1.0.0", error: "404 from CDN", code: "feed-error", }) 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", () => { - __setForTest({ state: "not-available", code: "dev-mode" }) + __setForTest({ state: "not-available", currentVersion: "1.0.0", code: "dev-mode" }) render(UpdateDialog, { props: { open: true } }) expect(bodyText()).toMatch(/packaged builds/i) }) diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte index 1c0138d7f..4ed9dbc87 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -39,8 +39,13 @@ let pendingChannel = $state(null) const s = $derived(updateStatus()) const lastChecked = $derived(lastCheckedAt()) -const sanitizedNotes = $derived(s.releaseNotes ? DOMPurify.sanitize(s.releaseNotes) : "") +const sanitizedNotes = $derived( + (s.state === "available" || s.state === "downloaded") && s.releaseNotes + ? DOMPurify.sanitize(s.releaseNotes) + : "", +) const headline = $derived(statusHeadline(s, appVersion)) +const installedVersion = $derived(s.currentVersion || appVersion || "") async function loadVersion(): Promise { try { @@ -108,23 +113,79 @@ onMount(async () => { {:else if s.state === "error"} - {:else} + {:else if s.state === "idle" || s.code === "dev-mode" || s.code === "channel-missing"} + {: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 if s.state === "idle"} +

No update check has run yet

+
+ {#if installedVersion} + Version {installedVersion} + {/if} + {channelLabel(releaseChannel)} channel +
+ {:else if s.code === "dev-mode"} +

Updates run in packaged builds

+ {:else if s.code === "channel-missing"} +

No releases on this channel yet

+
+ {channelLabel(releaseChannel)} channel + {#if lastChecked} + Last checked at {fmtTime(lastChecked)} + {/if} +
+ {: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} @@ -136,9 +197,20 @@ onMount(async () => {
{#if s.state === "available"} - + {:else if s.state === "downloaded"} + {:else if s.state === "error"} + {:else}