From bf1cfd904eb2c825f608a579362317cf1af48f8b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 1 Jul 2026 06:33:57 -0500 Subject: [PATCH 1/2] feat(desktop): re-imagine update UX with dedicated Updates tab Move updates into their own Settings tab as a single source of truth: a status hero with an affirmative up-to-date resting state, a Stable/ Preview channel picker (UI-only rename over the backend beta channel) with a downgrade confirmation, and honest 'Download updates automatically' copy matching the autoDownload backend semantics. Redesign UpdateDialog to share status/formatting helpers so the modal and the settings hero speak the same language. Delete the old inline UpdatesSection. Add periodic manifest polling: the app previously fetched the update manifest only once at boot, so long-running sessions never discovered new releases. It now re-checks every 6h (respecting the autoDownload toggle, skipping while a download is in flight) and clears the timer on quit. --- desktop/src/main/__tests__/updater.test.ts | 44 ++++ desktop/src/main/index.ts | 3 +- desktop/src/main/updater.ts | 27 +- .../lib/components/update/UpdateDialog.svelte | 16 +- .../components/update/UpdateDialog.test.ts | 2 +- .../lib/components/update/UpdatesPanel.svelte | 232 ++++++++++++++++++ .../components/update/UpdatesSection.svelte | 151 ------------ .../src/lib/components/update/channel.test.ts | 36 +++ .../src/lib/components/update/channel.ts | 39 +++ .../lib/components/update/status-copy.test.ts | 55 +++++ .../src/lib/components/update/status-copy.ts | 34 +++ .../renderer/src/pages/SettingsPage.svelte | 14 +- 12 files changed, 480 insertions(+), 173 deletions(-) create mode 100644 desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte delete mode 100644 desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte create mode 100644 desktop/src/renderer/src/lib/components/update/channel.test.ts create mode 100644 desktop/src/renderer/src/lib/components/update/channel.ts create mode 100644 desktop/src/renderer/src/lib/components/update/status-copy.test.ts create mode 100644 desktop/src/renderer/src/lib/components/update/status-copy.ts diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index a7e8d0bb2..8eee42310 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -129,4 +129,48 @@ 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("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..b10e75c3e 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -68,10 +68,17 @@ 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 recheckTimer: ReturnType | null = null function sendUpdateStatus(status: UpdateStatus): void { const win = getMainWindowFn?.() @@ -246,11 +253,27 @@ 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) + } + + setTimeout(runBackgroundCheck, INITIAL_CHECK_DELAY_MS) + recheckTimer = setInterval(runBackgroundCheck, RECHECK_INTERVAL_MS) +} + +export function stopAutoUpdater(): void { + 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..3637e1aac --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -0,0 +1,232 @@ + + +
+ +
+
+
+
+ {#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/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..d348c80b3 --- /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} is available` + case "downloading": + return `Downloading v${s.version} · ${(s.progress?.percent ?? 0).toFixed(0)}%` + case "downloaded": + return `Version ${s.version} 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) {
+ + +
+ +
+
+
From b490a5542c57c868aebc10f856c6fdac5387bf0b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 1 Jul 2026 10:01:23 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20clear=20boot=20timer,=20a11y,=20panel=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clear the pending initial-check setTimeout in stopAutoUpdater so a quit within the boot delay can't fire a check during teardown. - Add radiogroup/radio ARIA semantics to the release-channel picker so the selected channel is exposed to assistive tech. - Guard statusHeadline against a missing version field. - Add UpdatesPanel tests covering the upgrade, downgrade-confirm, and IPC-failure-revert channel-switch paths, plus a boot-timer cleanup test for the updater. --- desktop/src/main/__tests__/updater.test.ts | 17 +++ desktop/src/main/updater.ts | 7 +- .../lib/components/update/UpdatesPanel.svelte | 5 +- .../components/update/UpdatesPanel.test.ts | 111 ++++++++++++++++++ .../src/lib/components/update/status-copy.ts | 6 +- 5 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 desktop/src/renderer/src/lib/components/update/UpdatesPanel.test.ts diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index 8eee42310..1f7f1a161 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -153,6 +153,23 @@ describe("updater", () => { } }) + 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 { diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index b10e75c3e..b0e137858 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -78,6 +78,7 @@ 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 { @@ -265,11 +266,15 @@ export async function initAutoUpdater( }) } - setTimeout(runBackgroundCheck, INITIAL_CHECK_DELAY_MS) + 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 diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte index 3637e1aac..7805faa0c 100644 --- a/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte +++ b/desktop/src/renderer/src/lib/components/update/UpdatesPanel.svelte @@ -164,9 +164,12 @@ onMount(async () => {

Choose how early you receive new versions

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