diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index a7e8d0bb2..1f7f1a161 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -129,4 +129,65 @@ describe("updater", () => { electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "9.9.9" }) expect((electron.dialog.showMessageBox as ReturnType)).not.toHaveBeenCalled() }) + + it("checks at boot and again on the recheck interval", async () => { + vi.useFakeTimers() + try { + const { initAutoUpdater, stopAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + expect(electronUpdaterMock.autoUpdater.checkForUpdates).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(10_000) + expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) + expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2) + + stopAutoUpdater() + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) + expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it("clears the pending boot check when stopped before it fires", async () => { + vi.useFakeTimers() + try { + const { initAutoUpdater, stopAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + // Quit before the 10s boot delay elapses. + stopAutoUpdater() + await vi.advanceTimersByTimeAsync(10_000) + expect(electronUpdaterMock.autoUpdater.checkForUpdates).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it("skips the background check while a download is in flight", async () => { + vi.useFakeTimers() + try { + const { initAutoUpdater, stopAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + + await vi.advanceTimersByTimeAsync(10_000) + expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + + electronUpdaterMock.autoUpdater.emit("update-downloaded", { version: "9.9.9" }) + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) + expect(electronUpdaterMock.autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + + stopAutoUpdater() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 4debe1a46..7cf9fafa2 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -9,7 +9,7 @@ import { LogStore } from "./log-store.js" import { PtyManager } from "./pty.js" import { DaemonState } from "./state.js" import { AppTray } from "./tray.js" -import { initAutoUpdater } from "./updater.js" +import { initAutoUpdater, stopAutoUpdater } from "./updater.js" import { Watcher } from "./watcher.js" const PROTOCOL = "devsy" @@ -157,6 +157,7 @@ app.whenReady().then(() => { } daemonManager.stop() ptyManager.destroyAll() + stopAutoUpdater() }) // Register IPC handlers diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index b828288ee..b0e137858 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -68,10 +68,18 @@ function saveSettings(patch: PersistedSettings): void { } } +// Delay before the first check so it doesn't compete with app startup, then +// re-check on a fixed interval so long-running sessions still discover releases +// published after launch. +const INITIAL_CHECK_DELAY_MS = 10_000 +const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 + let currentChannel: ReleaseChannel = "stable" let autoDownloadEnabled = true let getMainWindowFn: (() => BrowserWindow | null) | null = null let lastStatus: UpdateStatus = { state: "idle" } +let initialCheckTimer: ReturnType | null = null +let recheckTimer: ReturnType | null = null function sendUpdateStatus(status: UpdateStatus): void { const win = getMainWindowFn?.() @@ -246,11 +254,31 @@ export async function initAutoUpdater( console.error("Auto-update error:", err.message) }) - setTimeout(() => { + const runBackgroundCheck = (): void => { + // Skip while a download is already in flight or staged — re-fetching the + // manifest would just churn state. autoUpdater.autoDownload is already set + // from the user's preference, so a plain check honors that toggle. + if (lastStatus.state === "downloading" || lastStatus.state === "downloaded") { + return + } autoUpdater.checkForUpdates().catch((err: Error) => { console.error("Update check failed:", err.message) }) - }, 10_000) + } + + initialCheckTimer = setTimeout(runBackgroundCheck, INITIAL_CHECK_DELAY_MS) + recheckTimer = setInterval(runBackgroundCheck, RECHECK_INTERVAL_MS) +} + +export function stopAutoUpdater(): void { + if (initialCheckTimer) { + clearTimeout(initialCheckTimer) + initialCheckTimer = null + } + if (recheckTimer) { + clearInterval(recheckTimer) + recheckTimer = null + } } async function getUpdater() { diff --git a/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte b/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte index 18147be60..7804eedb5 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte @@ -14,6 +14,7 @@ import { lastCheckedAt, } from "$lib/stores/updates.svelte.js" import { markUserInitiated } from "./update-toasts.js" +import { fmtMBps, fmtTime } from "./status-copy.js" let { open = $bindable(false), @@ -26,17 +27,6 @@ const sanitizedNotes = $derived( s.releaseNotes ? DOMPurify.sanitize(s.releaseNotes) : "", ) -function fmtMBps(bps: number): string { - if (!bps) return "" - const mbps = bps / 1_000_000 - return `${mbps.toFixed(2)} MB/s` -} - -function fmtTime(ts: number | null): string { - if (!ts) return "" - return new Date(ts).toLocaleTimeString() -} - async function onCheck() { markUserInitiated() await checkForUpdates() @@ -80,7 +70,7 @@ async function onInstall() {

Downloading v{s.version}…

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

{:else if s.state === "downloaded"} @@ -95,7 +85,7 @@ async function onInstall() { {/if}
- +
{:else if s.state === "not-available"} 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 6acc03d3b..ba83598bf 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts +++ b/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts @@ -44,7 +44,7 @@ describe("UpdateDialog", () => { __setForTest({ state: "downloaded", version: "9.9.9" }) render(UpdateDialog, { props: { open: true } }) expect(bodyText()).toMatch(/version 9\.9\.9/i) - expect(queryButton(/restart and update/i)).toBeTruthy() + expect(queryButton(/restart & update/i)).toBeTruthy() }) it("renders 'downloading' progress", () => { diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte new file mode 100644 index 000000000..7805faa0c --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -0,0 +1,235 @@ + + +
+ +
+
+
+
+ {#if s.state === "checking"} + + {:else if s.state === "available" || s.state === "downloading"} + + {:else if s.state === "downloaded"} + + {:else if s.state === "error"} + + {:else} + + {/if} +
+ +
+

{headline}

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

{s.error}

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

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

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

Last checked at {fmtTime(lastChecked)}

+ {/if} + + {#if (s.state === "available" || s.state === "downloaded") && sanitizedNotes} +
+ {@html sanitizedNotes} +
+ {/if} +
+ +
+ {#if s.state === "available"} + + {:else if s.state === "downloaded"} + + {:else} + + {/if} +
+
+
+
+ + + + +
+
+ +

Choose how early you receive new versions

+
+
+ {#each CHANNELS as c (c.value)} + + {/each} +
+
+ + + + +
+ +
+
+

Download updates automatically

+

+ Updates download in the background; you choose when to restart. +

+
+ setAutoUpdate(v)} /> +
+
+ + + + +
+
+

Devsy

+ {#if appVersion} +

v{appVersion}

+ {:else} +

Version unavailable

+ {/if} +
+ {channelLabel(releaseChannel)} channel +
+
+ + pendingChannel && applyChannel(pendingChannel)} +/> diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.test.ts b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.test.ts new file mode 100644 index 000000000..60ae6ec13 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.test.ts @@ -0,0 +1,111 @@ +import { tick } from "svelte" +import { render } from "@testing-library/svelte" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const getAppVersion = vi.fn() +const getReleaseChannel = vi.fn() +const setReleaseChannel = vi.fn() +const checkForUpdates = vi.fn() +const downloadUpdate = vi.fn() +const installUpdate = vi.fn() + +vi.mock("$lib/ipc/commands.js", () => ({ + getAppVersion: (...a: unknown[]) => getAppVersion(...a), + getReleaseChannel: (...a: unknown[]) => getReleaseChannel(...a), + setReleaseChannel: (...a: unknown[]) => setReleaseChannel(...a), + checkForUpdates: (...a: unknown[]) => checkForUpdates(...a), + downloadUpdate: (...a: unknown[]) => downloadUpdate(...a), + installUpdate: (...a: unknown[]) => installUpdate(...a), +})) + +const toastSuccess = vi.fn() +const toastError = vi.fn() +vi.mock("$lib/stores/toasts.js", () => ({ + toasts: { + success: (...a: unknown[]) => toastSuccess(...a), + error: (...a: unknown[]) => toastError(...a), + }, +})) + +vi.mock("./update-toasts.js", () => ({ markUserInitiated: vi.fn() })) + +vi.mock("$lib/ipc/events.js", async (importOriginal) => { + const mod = await importOriginal() + return { ...mod, onUpdateStatus: async () => () => {} } +}) + +import UpdatesPanel from "./UpdatesPanel.svelte" +import { __setForTest, initUpdateStore } from "$lib/stores/updates.svelte.js" + +function cardButton(label: RegExp): HTMLButtonElement | null { + return ( + Array.from(document.querySelectorAll('button[role="radio"]')).find( + (b) => label.test(b.textContent ?? ""), + ) ?? null + ) +} + +async function renderPanel(channel: "stable" | "beta") { + getAppVersion.mockResolvedValue("1.2.3") + getReleaseChannel.mockResolvedValue(channel) + setReleaseChannel.mockResolvedValue(undefined) + await initUpdateStore() + __setForTest({ state: "not-available", version: "1.2.3" }) + render(UpdatesPanel) + // Let onMount's async version/channel loads resolve. + await tick() + await Promise.resolve() + await tick() +} + +describe("UpdatesPanel channel switching", () => { + beforeEach(() => { + getAppVersion.mockReset() + getReleaseChannel.mockReset() + setReleaseChannel.mockReset() + toastSuccess.mockReset() + toastError.mockReset() + }) + + afterEach(() => { + document.body.innerHTML = "" + }) + + it("switches immediately and confirms via toast on a normal upgrade", async () => { + await renderPanel("stable") + + cardButton(/Preview/)?.click() + await tick() + await Promise.resolve() + + expect(setReleaseChannel).toHaveBeenCalledWith("beta") + expect(toastSuccess).toHaveBeenCalledWith("Switched to the Preview channel") + }) + + it("opens a confirmation instead of switching on a downgrade", async () => { + await renderPanel("beta") + + cardButton(/Stable/)?.click() + await tick() + + // Downgrade must wait for confirmation — no IPC call yet. + expect(setReleaseChannel).not.toHaveBeenCalled() + expect(document.body.textContent).toMatch(/switch to the stable channel\?/i) + }) + + it("reverts the selection and toasts on IPC failure", async () => { + await renderPanel("stable") + setReleaseChannel.mockRejectedValueOnce(new Error("boom")) + + const preview = cardButton(/Preview/) + preview?.click() + await tick() + await Promise.resolve() + await tick() + + expect(toastError).toHaveBeenCalled() + // Selection reverts to Stable. + expect(cardButton(/Stable/)?.getAttribute("aria-checked")).toBe("true") + expect(cardButton(/Preview/)?.getAttribute("aria-checked")).toBe("false") + }) +}) diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte deleted file mode 100644 index 9a600c613..000000000 --- a/desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte +++ /dev/null @@ -1,151 +0,0 @@ - - -

Updates

- -
-
-
- -

Download and install updates in the background

-
- setAutoUpdate(v)} /> -
- -
- -
- - -
-
-
- - - -
-

Version

-
-
-

Devsy

- {#if appVersion} -

v{appVersion}

- {:else} -

Version unavailable

- {/if} -
- -
- - {#if liveStatus.state !== "idle" && liveStatus.state !== "not-available"} -
-

- {#if liveStatus.state === "checking"} - Checking for updates… - {:else if liveStatus.state === "available"} - Update v{liveStatus.version} available - {:else if liveStatus.state === "downloading"} - Downloading v{liveStatus.version} · {(liveStatus.progress?.percent ?? 0).toFixed(0)}% - {:else if liveStatus.state === "downloaded"} - Update v{liveStatus.version} ready to install - {:else if liveStatus.state === "error"} - Update error: {liveStatus.error} - {/if} -

- -
- {/if} -
diff --git a/desktop/src/renderer/src/lib/components/update/channel.test.ts b/desktop/src/renderer/src/lib/components/update/channel.test.ts new file mode 100644 index 000000000..57c45dfa9 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/channel.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest" +import { CHANNELS, channelLabel, isDowngrade } from "./channel.js" + +describe("channel labels", () => { + it("maps beta to the Preview display label", () => { + expect(channelLabel("beta")).toBe("Preview") + }) + + it("maps stable to Stable", () => { + expect(channelLabel("stable")).toBe("Stable") + }) + + it("exposes both channels with backend values intact", () => { + expect(CHANNELS.map((c) => c.value)).toEqual(["stable", "beta"]) + }) + + it("flags Preview as unstable", () => { + expect(CHANNELS.find((c) => c.value === "beta")?.unstable).toBe(true) + expect(CHANNELS.find((c) => c.value === "stable")?.unstable).toBe(false) + }) +}) + +describe("isDowngrade", () => { + it("treats Preview → Stable as a downgrade", () => { + expect(isDowngrade("beta", "stable")).toBe(true) + }) + + it("treats Stable → Preview as not a downgrade", () => { + expect(isDowngrade("stable", "beta")).toBe(false) + }) + + it("treats same-channel as not a downgrade", () => { + expect(isDowngrade("beta", "beta")).toBe(false) + expect(isDowngrade("stable", "stable")).toBe(false) + }) +}) diff --git a/desktop/src/renderer/src/lib/components/update/channel.ts b/desktop/src/renderer/src/lib/components/update/channel.ts new file mode 100644 index 000000000..2679d6ba1 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/channel.ts @@ -0,0 +1,39 @@ +import type { ReleaseChannel } from "$lib/ipc/commands.js" + +// UI-only presentation over the backend channel values. The persisted +// `update-settings.json` and electron-updater channel still use +// "stable"/"beta"; "Preview" is purely a display label mapped here. +export interface ChannelMeta { + value: ReleaseChannel + label: string + cadence: string + description: string + unstable: boolean +} + +export const CHANNELS: ChannelMeta[] = [ + { + value: "stable", + label: "Stable", + cadence: "Released on a regular schedule", + description: "Production-ready builds, tested before release.", + unstable: false, + }, + { + value: "beta", + label: "Preview", + cadence: "Updated frequently", + description: "Early access to new features. May be unstable.", + unstable: true, + }, +] + +export function channelLabel(value: ReleaseChannel): string { + return CHANNELS.find((c) => c.value === value)?.label ?? value +} + +// Moving from Preview back to Stable can leave the user on a newer build +// than the latest Stable release, so it warrants a confirmation. +export function isDowngrade(from: ReleaseChannel, to: ReleaseChannel): boolean { + return from === "beta" && to === "stable" +} 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 new file mode 100644 index 000000000..7d1af3af6 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/status-copy.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest" +import { fmtMBps, statusHeadline } from "./status-copy.js" + +describe("fmtMBps", () => { + it("formats bytes/sec as MB/s", () => { + expect(fmtMBps(1_500_000)).toBe("1.50 MB/s") + }) + + it("returns empty for zero/undefined", () => { + expect(fmtMBps(0)).toBe("") + expect(fmtMBps(undefined)).toBe("") + }) +}) + +describe("statusHeadline", () => { + it("announces up-to-date with version", () => { + expect(statusHeadline({ state: "not-available" }, "1.2.3")).toBe( + "Devsy is up to date · v1.2.3", + ) + }) + + it("announces an available version", () => { + expect(statusHeadline({ state: "available", version: "2.0.0" }, "1.2.3")).toBe( + "Version 2.0.0 is available", + ) + }) + + it("announces download progress", () => { + expect( + statusHeadline( + { + state: "downloading", + version: "2.0.0", + progress: { percent: 42, bytesPerSecond: 0, transferred: 0, total: 0 }, + }, + "1.2.3", + ), + ).toBe("Downloading v2.0.0 · 42%") + }) + + it("announces ready-to-install", () => { + expect(statusHeadline({ state: "downloaded", version: "2.0.0" }, "1.2.3")).toBe( + "Version 2.0.0 is ready to install", + ) + }) + + it("distinguishes dev-mode and channel-missing", () => { + expect(statusHeadline({ state: "not-available", code: "dev-mode" }, null)).toBe( + "Updates run in packaged builds", + ) + expect( + statusHeadline({ state: "not-available", code: "channel-missing" }, "1.2.3"), + ).toBe("No releases on this channel 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 new file mode 100644 index 000000000..ce14756f3 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/status-copy.ts @@ -0,0 +1,34 @@ +import type { UpdateStatus } from "$lib/ipc/events.js" + +export function fmtMBps(bps: number | undefined): string { + if (!bps) return "" + return `${(bps / 1_000_000).toFixed(2)} MB/s` +} + +export function fmtTime(ts: number | null): string { + if (!ts) return "" + return new Date(ts).toLocaleTimeString() +} + +// One-line summary of the update state, shared by the settings hero and the +// standalone dialog so both surfaces read identically. +export function statusHeadline(s: UpdateStatus, currentVersion: string | null): string { + switch (s.state) { + case "checking": + return "Checking for updates…" + case "available": + return `Version ${s.version ?? "unknown"} is available` + case "downloading": + return `Downloading v${s.version ?? "?"} · ${(s.progress?.percent ?? 0).toFixed(0)}%` + case "downloaded": + return `Version ${s.version ?? "unknown"} is ready to install` + case "error": + return "Update check failed" + 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" + return currentVersion ? `Devsy is up to date · v${currentVersion}` : "Devsy is up to date" + default: + return currentVersion ? `Devsy v${currentVersion}` : "Devsy" + } +} diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index 421536adc..c9063033b 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -29,7 +29,7 @@ import type { UIScale, LocalOptions, } from "$lib/stores/settings.js" -import UpdatesSection from "$lib/components/update/UpdatesSection.svelte" +import UpdatesPanel from "$lib/components/update/UpdatesPanel.svelte" import { Skeleton } from "$lib/components/ui/skeleton/index.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" @@ -163,6 +163,7 @@ function toggleLocal(key: keyof LocalOptions) { General Appearance + Updates Experimental @@ -261,10 +262,6 @@ function toggleLocal(key: keyof LocalOptions) { - - - -

Keyboard Shortcuts

@@ -339,6 +336,13 @@ function toggleLocal(key: keyof LocalOptions) {
+ + +
+ +
+
+