diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69677a7a7..58bc9592f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -210,6 +210,14 @@ jobs: desktop/release/*.deb desktop/release/*.rpm + - name: rewrite manifest URLs to GitHub Release assets + if: hashFiles('desktop/release/latest*.yml', 'desktop/release/beta*.yml') != '' + env: + REPO: ${{ github.repository }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + go run hack/rewrite_manifest_urls/main.go desktop/release/ "$REPO" "$TAG" + - name: upload update metadata artifact uses: actions/upload-artifact@v7 with: @@ -256,15 +264,11 @@ jobs: pattern: update-metadata-* path: metadata/ - - name: fetch existing metadata from live site - run: | - mkdir -p publish-dir/desktop - for file in latest-mac.yml latest-linux.yml latest.yml beta-mac.yml beta-linux.yml beta.yml; do - curl -sSf "https://dl.devsy.sh/desktop/$file" -o "publish-dir/desktop/$file" 2>/dev/null || true - done + - name: prepare publish dir + run: mkdir -p publish-dir/desktop - name: merge macOS metadata from separate arch builds - run: go run hack/merge_mac_metadata.go metadata/ publish-dir/desktop/ + run: go run hack/merge_mac_metadata/main.go metadata/ publish-dir/desktop/ - name: overlay non-mac metadata files (stable release) if: ${{ !github.event.release.prerelease }} @@ -280,6 +284,11 @@ jobs: find metadata/ -name 'beta-linux.yml' -exec cp {} publish-dir/desktop/ \; find metadata/ -name 'beta.yml' -exec cp {} publish-dir/desktop/ \; + - name: smoke-test manifest URLs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: go run hack/smoke_test_manifests/main.go publish-dir/desktop/ + - name: deploy to Netlify (dl.devsy.sh) uses: nwtgck/actions-netlify@v3 with: diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 7116020c8..a90b42513 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@types/dompurify": "^3.0.5", "chokidar": "^4.0.0", + "dompurify": "^3.4.7", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", "posthog-node": "^5.34.2", @@ -2611,6 +2613,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2688,7 +2699,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@types/verror": { @@ -4381,6 +4391,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", diff --git a/desktop/package.json b/desktop/package.json index 7671faab1..1c86da7c3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "chokidar": "^4.0.0", + "dompurify": "^3.4.7", "electron-updater": "^6.8.3", "node-pty": "^1.0.0", "posthog-node": "^5.34.2", @@ -29,6 +30,7 @@ }, "devDependencies": { "@electron/rebuild": "^3.7.0", + "@types/dompurify": "^3.0.5", "@internationalized/date": "^3.12.1", "@lucide/svelte": "^1.8.0", "@playwright/test": "^1.50.0", diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts new file mode 100644 index 000000000..a09095840 --- /dev/null +++ b/desktop/src/main/__tests__/tray.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from "vitest" +import { buildUpdateMenuItems } from "../tray.js" + +vi.mock("electron", () => ({})) +vi.mock("../updater.js", () => ({ + getLastStatus: () => ({ state: "idle" }), + installUpdate: vi.fn(), +})) + +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([]) + }) + + it("adds Install Update item + separator when downloaded", () => { + const onInstall = vi.fn() + const items = buildUpdateMenuItems({ state: "downloaded", version: "9.9.9" }, onInstall) + expect(items).toHaveLength(2) + expect(items[0]).toMatchObject({ label: "Install Update v9.9.9" }) + expect(items[1]).toEqual({ type: "separator" }) + + const click = (items[0] as { click?: () => void }).click + click?.() + expect(onInstall).toHaveBeenCalledTimes(1) + }) + + it("handles missing version gracefully", () => { + const items = buildUpdateMenuItems({ state: "downloaded" }, () => {}) + expect(items[0]).toMatchObject({ label: "Install Update v" }) + }) +}) diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts new file mode 100644 index 000000000..697c35e3a --- /dev/null +++ b/desktop/src/main/__tests__/updater.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +const electronUpdaterMock = { + autoUpdater: { + autoDownload: true, + autoInstallOnAppQuit: true, + allowPrerelease: false, + channel: "latest", + handlers: new Map void>(), + on(event: string, cb: (...args: unknown[]) => void) { + this.handlers.set(event, cb) + return this + }, + emit(event: string, ...args: unknown[]) { + this.handlers.get(event)?.(...args) + }, + checkForUpdates: vi.fn().mockResolvedValue(undefined), + downloadUpdate: vi.fn().mockResolvedValue(undefined), + quitAndInstall: vi.fn(), + }, +} + +vi.mock("electron-updater", () => electronUpdaterMock) +vi.mock("electron", () => ({ + app: { + isPackaged: true, + getPath: () => "/tmp/devsy-test", + getVersion: () => "1.0.0", + }, + dialog: { showMessageBox: vi.fn() }, +})) +vi.mock("../analytics.js", () => ({ trackEvent: vi.fn() })) + +describe("updater", () => { + beforeEach(async () => { + electronUpdaterMock.autoUpdater.handlers.clear() + electronUpdaterMock.autoUpdater.checkForUpdates.mockClear() + electronUpdaterMock.autoUpdater.downloadUpdate.mockClear() + vi.resetModules() + // Restore isPackaged on every test so an early throw in one test + // cannot silently flip later tests into the dev-mode branch. + const electron = await import("electron") + ;(electron.app as { isPackaged: boolean }).isPackaged = true + }) + + it("emits dev-mode status when app is not packaged", async () => { + const electron = await import("electron") + ;(electron.app as { isPackaged: boolean }).isPackaged = false + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ state: "not-available", code: "dev-mode" }), + ) + }) + + it("emits downloading state with progress info", async () => { + const { initAutoUpdater } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + electronUpdaterMock.autoUpdater.emit("download-progress", { + percent: 42, + bytesPerSecond: 1000, + transferred: 100, + total: 200, + }) + expect(send).toHaveBeenCalledWith( + "update-status", + expect.objectContaining({ + state: "downloading", + progress: { percent: 42, bytesPerSecond: 1000, transferred: 100, total: 200 }, + }), + ) + }) + + it("respects autoDownload setting on update-available", async () => { + const { initAutoUpdater, setAutoDownloadEnabled } = await import("../updater.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + setAutoDownloadEnabled(false) + electronUpdaterMock.autoUpdater.emit("update-available", { version: "9.9.9" }) + expect(electronUpdaterMock.autoUpdater.downloadUpdate).not.toHaveBeenCalled() + }) + + it("does not fire a native dialog on update-downloaded", async () => { + const electron = await import("electron") + 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-downloaded", { version: "9.9.9" }) + expect((electron.dialog.showMessageBox as ReturnType)).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index fa6ebca3a..a80092237 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import type { BrowserWindow } from "electron" -import { ipcMain } from "electron" +import { app, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" import { trackEvent } from "./analytics.js" import type { CliRunner } from "./cli.js" @@ -16,8 +16,11 @@ import { type ReleaseChannel, checkForUpdates, checkForUpdatesWithChannel, + downloadUpdate, + getAutoDownloadEnabled, getReleaseChannel, installUpdate, + setAutoDownloadEnabled, setReleaseChannel, } from "./updater.js" import { type ProviderEntry, parseProviderEntries } from "./watcher.js" @@ -787,8 +790,17 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M if (args.channel !== "stable" && args.channel !== "beta") { throw new Error(`Invalid release channel: ${args.channel}`) } - setReleaseChannel(args.channel) - await checkForUpdatesWithChannel(args.channel) + const channel: ReleaseChannel = args.channel + const previous = getReleaseChannel() + setReleaseChannel(channel) + try { + await checkForUpdatesWithChannel(channel) + } catch (err) { + // Rollback persisted choice so disk + renderer stay in sync if + // the renderer reverts its UI state. + setReleaseChannel(previous) + throw err + } }, ) @@ -800,6 +812,28 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M installUpdate() }) + ipcMain.handle("download_update", async () => { + await downloadUpdate() + }) + + ipcMain.handle("get_app_version", () => { + return app.getVersion() + }) + + ipcMain.handle("get_auto_download", () => { + return getAutoDownloadEnabled() + }) + + ipcMain.handle( + "set_auto_download", + async (_event, args: { enabled: boolean }) => { + if (typeof args?.enabled !== "boolean") { + throw new Error("enabled must be boolean") + } + setAutoDownloadEnabled(args.enabled) + }, + ) + // ── Analytics ── ipcMain.handle( "analytics_track", diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 7f90ffd30..859c5eae1 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -2,6 +2,22 @@ import { join } from "node:path" import type { BrowserWindow } from "electron" import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" import type { DaemonState } from "./state.js" +import { getLastStatus, installUpdate, type UpdateStatus } from "./updater.js" + +/** + * Builds the menu items shown when an update is ready. Returns an empty + * array when no install action should be offered. Exported for unit testing. + */ +export function buildUpdateMenuItems( + status: UpdateStatus, + onInstall: () => void, +): Electron.MenuItemConstructorOptions[] { + if (status.state !== "downloaded") return [] + return [ + { label: `Install Update v${status.version ?? ""}`, click: onInstall }, + { type: "separator" }, + ] +} interface TrayDeps { state: DaemonState @@ -86,6 +102,9 @@ export class AppTray { : `${count} workspace${count === 1 ? "" : "s"}` const template: Electron.MenuItemConstructorOptions[] = [ + ...buildUpdateMenuItems(getLastStatus(), () => { + installUpdate().catch(() => {}) + }), { label: statusLabel, enabled: false }, ] diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index 17e79ad51..a8c72b544 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -1,38 +1,76 @@ -import { readFileSync, writeFileSync } from "node:fs" +import { readFileSync, renameSync, writeFileSync } from "node:fs" import { join } from "node:path" -import { app, dialog, type BrowserWindow } from "electron" +import { app, type BrowserWindow } from "electron" import { trackEvent } from "./analytics.js" export type ReleaseChannel = "stable" | "beta" +export type UpdateStateValue = + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded" + | "not-available" + | "error" + +export type UpdateErrorCode = + | "dev-mode" + | "unsupported" + | "network" + | "feed-error" + | "verification" + +export interface UpdateProgress { + percent: number + bytesPerSecond: number + transferred: number + total: number +} + export interface UpdateStatus { - state: "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error" + state: UpdateStateValue version?: string releaseNotes?: string releaseName?: string + progress?: UpdateProgress error?: string + code?: UpdateErrorCode +} + +interface PersistedSettings { + channel?: ReleaseChannel + autoDownload?: boolean } function settingsPath(): string { return join(app.getPath("userData"), "update-settings.json") } -function loadChannel(): ReleaseChannel { +function loadSettings(): PersistedSettings { try { - const data = JSON.parse(readFileSync(settingsPath(), "utf-8")) - if (data.channel === "beta") return "beta" + return JSON.parse(readFileSync(settingsPath(), "utf-8")) as PersistedSettings } catch { - // File doesn't exist or is corrupt + return {} } - return "stable" } -function saveChannel(channel: ReleaseChannel): void { - writeFileSync(settingsPath(), JSON.stringify({ channel })) +function saveSettings(patch: PersistedSettings): void { + try { + const current = loadSettings() + const target = settingsPath() + const tmp = `${target}.tmp` + writeFileSync(tmp, JSON.stringify({ ...current, ...patch })) + renameSync(tmp, target) + } catch (err) { + console.warn("[updater] failed to persist settings:", err) + } } let currentChannel: ReleaseChannel = "stable" +let autoDownloadEnabled = true let getMainWindowFn: (() => BrowserWindow | null) | null = null +let lastStatus: UpdateStatus = { state: "idle" } function sendUpdateStatus(status: UpdateStatus): void { const win = getMainWindowFn?.() @@ -41,6 +79,15 @@ function sendUpdateStatus(status: UpdateStatus): void { } } +function setStatus(status: UpdateStatus): void { + lastStatus = status + sendUpdateStatus(status) +} + +export function getLastStatus(): UpdateStatus { + return lastStatus +} + function normalizeReleaseNotes( notes: string | { note: string }[] | null | undefined, ): string | undefined { @@ -50,40 +97,80 @@ function normalizeReleaseNotes( return undefined } +function classifyError(err: Error): UpdateErrorCode { + const m = err.message.toLowerCase() + if (m.includes("net::") || m.includes("network") || m.includes("enotfound")) return "network" + if (m.includes("sha512") || m.includes("checksum") || m.includes("integrity")) return "verification" + return "feed-error" +} + export function setReleaseChannel(channel: ReleaseChannel): void { currentChannel = channel - saveChannel(channel) + saveSettings({ channel }) } export function getReleaseChannel(): ReleaseChannel { return currentChannel } +export function setAutoDownloadEnabled(enabled: boolean): void { + autoDownloadEnabled = enabled + saveSettings({ autoDownload: enabled }) + // Update the live autoUpdater too so the change takes effect this session. + // electron-updater reads autoDownload at the moment update-available fires. + if (app.isPackaged) { + import("electron-updater") + .then(({ autoUpdater }) => { + if (autoUpdater && typeof autoUpdater === "object") { + autoUpdater.autoDownload = enabled + } + }) + .catch(() => {}) + } +} + +export function getAutoDownloadEnabled(): boolean { + return autoDownloadEnabled +} + export async function initAutoUpdater( getMainWindow: () => BrowserWindow | null, ): Promise { getMainWindowFn = getMainWindow - currentChannel = loadChannel() + + const settings = loadSettings() + currentChannel = settings.channel ?? "stable" + autoDownloadEnabled = settings.autoDownload ?? true + + if (!app.isPackaged) { + setStatus({ state: "not-available", code: "dev-mode" }) + return + } + const { autoUpdater } = await import("electron-updater") if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { - console.warn("Auto-updater not available (app is not code-signed)") + setStatus({ + state: "error", + code: "unsupported", + error: "Updates require a packaged build", + }) return } - autoUpdater.autoDownload = true + autoUpdater.autoDownload = autoDownloadEnabled autoUpdater.autoInstallOnAppQuit = true autoUpdater.allowPrerelease = currentChannel === "beta" autoUpdater.channel = currentChannel === "beta" ? "beta" : "latest" autoUpdater.on("checking-for-update", () => { trackEvent("update_check") - sendUpdateStatus({ state: "checking" }) + setStatus({ state: "checking" }) }) autoUpdater.on("update-available", (info) => { trackEvent("update_available", { version: info.version }) - sendUpdateStatus({ + setStatus({ state: "available", version: info.version, releaseName: info.releaseName ?? undefined, @@ -92,44 +179,42 @@ export async function initAutoUpdater( }) autoUpdater.on("update-not-available", (info) => { - sendUpdateStatus({ + setStatus({ state: "not-available", version: info.version, }) }) + autoUpdater.on("download-progress", (info) => { + setStatus({ + ...lastStatus, + state: "downloading", + progress: { + percent: info.percent, + bytesPerSecond: info.bytesPerSecond, + transferred: info.transferred, + total: info.total, + }, + }) + }) + autoUpdater.on("update-downloaded", (info) => { trackEvent("update_downloaded", { version: info.version }) - sendUpdateStatus({ + setStatus({ state: "downloaded", version: info.version, releaseName: info.releaseName ?? undefined, releaseNotes: normalizeReleaseNotes(info.releaseNotes), }) - - const win = getMainWindow() - if (!win) return - - dialog - .showMessageBox(win, { - type: "info", - title: "Update Ready", - message: `Version ${info.version} has been downloaded and will be installed on restart.`, - buttons: ["Restart Now", "Later"], - defaultId: 0, - cancelId: 1, - }) - .then(({ response }) => { - if (response === 0) { - trackEvent("update_installed", { version: info.version }) - autoUpdater.quitAndInstall() - } - }) }) autoUpdater.on("error", (err) => { trackEvent("update_error", { error_type: err.name }) - sendUpdateStatus({ state: "error", error: err.message }) + setStatus({ + state: "error", + code: classifyError(err), + error: err.message, + }) console.error("Auto-update error:", err.message) }) @@ -140,40 +225,48 @@ export async function initAutoUpdater( }, 10_000) } -export async function checkForUpdates(): Promise { +async function getUpdater() { + if (!app.isPackaged) { + setStatus({ state: "not-available", code: "dev-mode" }) + return null + } const { autoUpdater } = await import("electron-updater") if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { - sendUpdateStatus({ + setStatus({ state: "error", - error: "Auto-update is not available (app is not code-signed)", + code: "unsupported", + error: "Updates require a packaged build", }) - return + return null } + return autoUpdater +} + +export async function checkForUpdates(): Promise { + const autoUpdater = await getUpdater() + if (!autoUpdater) return await autoUpdater.checkForUpdates() } -export async function checkForUpdatesWithChannel( - channel: ReleaseChannel, -): Promise { - const { autoUpdater } = await import("electron-updater") +export async function checkForUpdatesWithChannel(channel: ReleaseChannel): Promise { + // Caller (set_release_channel IPC) already persisted the channel choice. + // Just reconfigure the running autoUpdater and kick off a check. currentChannel = channel - saveChannel(channel) - if (!autoUpdater || typeof autoUpdater.checkForUpdates !== "function") { - sendUpdateStatus({ - state: "error", - error: "Auto-update is not available (app is not code-signed)", - }) - return - } + const autoUpdater = await getUpdater() + if (!autoUpdater) return autoUpdater.allowPrerelease = channel === "beta" autoUpdater.channel = channel === "beta" ? "beta" : "latest" await autoUpdater.checkForUpdates() } +export async function downloadUpdate(): Promise { + const autoUpdater = await getUpdater() + if (!autoUpdater) return + await autoUpdater.downloadUpdate() +} + export async function installUpdate(): Promise { - const { autoUpdater } = await import("electron-updater") - if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") { - return - } + const autoUpdater = await getUpdater() + if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") return autoUpdater.quitAndInstall() } diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index 9ec94d72b..9d7c7f363 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -13,11 +13,15 @@ import { initWorkspaces, destroyWorkspaces } from "$lib/stores/workspaces.js" import { initProviders, destroyProviders } from "$lib/stores/providers.js" import { initMachines, destroyMachines } from "$lib/stores/machines.js" import { initContexts, destroyContexts } from "$lib/stores/contexts.js" -import { initSettings } from "$lib/stores/settings.js" +import { initSettings, syncAutoUpdateFromMain, autoUpdate } from "$lib/stores/settings.js" import { terminalCount } from "$lib/stores/terminals.js" import { togglePalette } from "$lib/stores/command-palette.js" import { appReady, analyticsTrack } from "$lib/ipc/commands.js" import { location } from "$lib/router.js" +import UpdateBadge from "$lib/components/update/UpdateBadge.svelte" +import UpdateDialog from "$lib/components/update/UpdateDialog.svelte" +import { initUpdateStore, disposeUpdateStore } from "$lib/stores/updates.svelte.js" +import { initUpdateToasts, bindDialogOpener } from "$lib/components/update/update-toasts.js" import DashboardPage from "./pages/DashboardPage.svelte" import WorkspacesPage from "./pages/WorkspacesPage.svelte" @@ -52,6 +56,9 @@ const routes = { let destroySettings: (() => void) | undefined +let updateDialogOpen = $state(false) +let unsubscribeToasts: (() => void) | null = null + const NAV_KEYS: Record = { 1: "/", 2: "/workspaces", @@ -89,7 +96,7 @@ function normalizeAnalyticsPath(path: string): string { return path } -onMount(() => { +onMount(async () => { initWorkspaces() initProviders() initMachines() @@ -104,9 +111,20 @@ onMount(() => { appReady().catch((err) => { console.warn("[Devsy] appReady failed:", err) }) + + await initUpdateStore() + await syncAutoUpdateFromMain() + unsubscribeToasts = initUpdateToasts(() => { + let value = true + autoUpdate.subscribe((v) => (value = v))() + return value + }) + bindDialogOpener(() => (updateDialogOpen = true)) }) onDestroy(() => { + unsubscribeToasts?.() + disposeUpdateStore() unsubLocation?.() destroyWorkspaces() destroyProviders() @@ -128,6 +146,7 @@ onDestroy(() => {
+ (updateDialogOpen = true)} />
@@ -139,5 +158,6 @@ onDestroy(() => { + diff --git a/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte b/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte new file mode 100644 index 000000000..5185d6bfa --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte @@ -0,0 +1,37 @@ + + +{#if show} + +{/if} diff --git a/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte b/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte new file mode 100644 index 000000000..3493ab4fe --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte @@ -0,0 +1,131 @@ + + + + + + Application Updates + + + {#if s.state === "checking"} +

Checking for updates…

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

+ Version {s.version} is available. +

+ {#if s.releaseNotes} +
+ {@html sanitizedNotes} +
+ {/if} + {#if autoDownloadEnabled} +

Downloading in the background…

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

Downloading v{s.version}…

+ +

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

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

+ Version {s.version} is ready to install. +

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

Updates are available in packaged builds.

+ {:else} +
+

You're on the latest version.

+ {#if lastChecked} +

Last checked at {fmtTime(lastChecked)}

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

Update check failed: {s.error}

+ +
+ {:else} +
+

No update check has run yet.

+ +
+ {/if} +
+
diff --git a/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts b/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts new file mode 100644 index 000000000..6acc03d3b --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render } from "@testing-library/svelte" + +vi.mock("$lib/ipc/commands.js", () => ({ + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + installUpdate: vi.fn(), +})) +vi.mock("$lib/ipc/events.js", async (importOriginal) => { + const mod = await importOriginal() + return { ...mod, onUpdateStatus: async () => () => {} } +}) + +import UpdateDialog from "./UpdateDialog.svelte" +import { + __setForTest, + initUpdateStore, +} from "$lib/stores/updates.svelte.js" + +function bodyText(): string { + return document.body.textContent ?? "" +} + +function queryButton(label: RegExp): HTMLButtonElement | null { + return ( + Array.from(document.querySelectorAll("button")).find((b) => + label.test(b.textContent ?? ""), + ) ?? null + ) +} + +describe("UpdateDialog", () => { + beforeEach(async () => { + await initUpdateStore() + }) + + it("renders 'checking' state", () => { + __setForTest({ state: "checking" }) + 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" }) + render(UpdateDialog, { props: { open: true } }) + expect(bodyText()).toMatch(/version 9\.9\.9/i) + expect(queryButton(/restart and update/i)).toBeTruthy() + }) + + it("renders 'downloading' progress", () => { + __setForTest({ + state: "downloading", + version: "9.9.9", + progress: { + percent: 42, + bytesPerSecond: 1_500_000, + transferred: 1, + total: 2, + }, + }) + render(UpdateDialog, { props: { open: true } }) + expect(bodyText()).toMatch(/42%/) + expect(bodyText()).toMatch(/1\.50 MB\/s/) + }) + + it("renders 'error' with retry", () => { + __setForTest({ + state: "error", + 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() + }) + + it("renders dev-mode hint in not-available + dev-mode", () => { + __setForTest({ state: "not-available", code: "dev-mode" }) + render(UpdateDialog, { props: { open: true } }) + expect(bodyText()).toMatch(/packaged builds/i) + }) +}) diff --git a/desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte b/desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte new file mode 100644 index 000000000..b7713abc8 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte @@ -0,0 +1,153 @@ + + +

Updates

+ +
+
+
+ +

Download and install updates in the background

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

Version

+
+
+

Devsy

+ {#if appVersion} +

v{appVersion}

+ {:else} +

Version unavailable

+ {/if} +
+ +
+ + {#if liveStatus.state !== "idle"} +
+

+ {#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 === "not-available"} + {liveStatus.code === "dev-mode" ? "Updates available in packaged builds" : "You're on the latest version"} + {:else if liveStatus.state === "error"} + Update error: {liveStatus.error} + {/if} +

+ +
+ {/if} +
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 new file mode 100644 index 000000000..0210a35f8 --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/update-toasts.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +const toastFns = { + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + default: vi.fn(), +} + +vi.mock("svelte-sonner", () => { + const fn = Object.assign( + (...args: unknown[]) => toastFns.default(...args), + toastFns, + ) + return { toast: fn } +}) + +vi.mock("$lib/ipc/commands.js", () => ({ + installUpdate: vi.fn(), +})) + +let listeners: Array<(s: unknown) => void> = [] +vi.mock("$lib/stores/updates.svelte.js", () => ({ + subscribe(fn: (s: unknown) => void) { + listeners.push(fn) + return () => { + const i = listeners.indexOf(fn) + if (i >= 0) listeners.splice(i, 1) + } + }, +})) + +describe("update-toasts", () => { + beforeEach(() => { + listeners = [] + toastFns.info.mockClear() + toastFns.success.mockClear() + toastFns.error.mockClear() + toastFns.default.mockClear() + vi.resetModules() + }) + + it("fires the available toast once per version even with repeat events", async () => { + const { initUpdateToasts } = await import("./update-toasts.js") + initUpdateToasts(() => true) + expect(listeners.length).toBe(1) + const emit = listeners[0] + + emit({ state: "available", version: "1.0.0" }) + emit({ state: "available", version: "1.0.0" }) + emit({ state: "available", version: "1.0.0" }) + + expect(toastFns.info).toHaveBeenCalledTimes(1) + }) + + it("re-fires when version changes", async () => { + const { initUpdateToasts } = await import("./update-toasts.js") + initUpdateToasts(() => true) + const emit = listeners[0] + + emit({ state: "available", version: "1.0.0" }) + emit({ state: "available", version: "1.0.1" }) + + expect(toastFns.info).toHaveBeenCalledTimes(2) + }) + + it("stays silent on background error (no markUserInitiated)", async () => { + const { initUpdateToasts } = await import("./update-toasts.js") + initUpdateToasts(() => true) + const emit = listeners[0] + + emit({ state: "error", error: "feed down", code: "feed-error" }) + + expect(toastFns.error).not.toHaveBeenCalled() + }) + + it("fires error toast after markUserInitiated, then resets the flag", async () => { + const { initUpdateToasts, markUserInitiated } = await import("./update-toasts.js") + initUpdateToasts(() => true) + const emit = listeners[0] + + markUserInitiated() + emit({ state: "error", error: "feed down", code: "feed-error" }) + expect(toastFns.error).toHaveBeenCalledTimes(1) + + // Different error to bypass dedupe; flag should already be reset, so silent. + emit({ state: "error", error: "different", code: "network" }) + expect(toastFns.error).toHaveBeenCalledTimes(1) + }) + + it("suppresses error toast when code is dev-mode", async () => { + const { initUpdateToasts, markUserInitiated } = await import("./update-toasts.js") + initUpdateToasts(() => true) + const emit = listeners[0] + + markUserInitiated() + emit({ state: "error", error: "x", code: "dev-mode" }) + + expect(toastFns.error).not.toHaveBeenCalled() + }) + + it("openUpdateDialog calls the bound opener", async () => { + const { bindDialogOpener, openUpdateDialog } = await import("./update-toasts.js") + const opener = vi.fn() + bindDialogOpener(opener) + openUpdateDialog() + expect(opener).toHaveBeenCalledTimes(1) + }) + + it("fires action toast (not info) when auto-download is off", async () => { + const { initUpdateToasts } = await import("./update-toasts.js") + initUpdateToasts(() => false) + const emit = listeners[0] + + emit({ state: "available", version: "1.0.0" }) + + expect(toastFns.info).not.toHaveBeenCalled() + expect(toastFns.default).toHaveBeenCalledTimes(1) + }) + + it("re-fires available after a downloading transition for a new version", async () => { + const { initUpdateToasts } = await import("./update-toasts.js") + initUpdateToasts(() => true) + const emit = listeners[0] + + emit({ state: "available", version: "1.0.0" }) + emit({ state: "downloading", version: "1.0.0", progress: { percent: 50, bytesPerSecond: 0, transferred: 0, total: 0 } }) + emit({ state: "available", version: "2.0.0" }) + + expect(toastFns.info).toHaveBeenCalledTimes(2) + }) +}) diff --git a/desktop/src/renderer/src/lib/components/update/update-toasts.ts b/desktop/src/renderer/src/lib/components/update/update-toasts.ts new file mode 100644 index 000000000..b142dc69c --- /dev/null +++ b/desktop/src/renderer/src/lib/components/update/update-toasts.ts @@ -0,0 +1,79 @@ +import { toast } from "svelte-sonner" +import { installUpdate } from "$lib/ipc/commands.js" +import type { UpdateStatus } from "$lib/ipc/events.js" +import { subscribe } from "$lib/stores/updates.svelte.js" + +let openDialog: (() => void) | null = null +let lastKey = "" +let userInitiated = false + +function dedupeKey(s: UpdateStatus): string { + // Include error text and code so consecutive errors (or a retry after + // an error) are not suppressed. + return [s.state, s.version ?? "", s.code ?? "", s.error ?? ""].join("") +} + +export function bindDialogOpener(fn: () => void): void { + openDialog = fn +} + +export function openUpdateDialog(): void { + openDialog?.() +} + +export function markUserInitiated(): void { + userInitiated = true +} + +function fireAvailable(s: UpdateStatus, autoDownload: boolean): void { + if (autoDownload) { + toast.info(`Update v${s.version} found, downloading…`, { duration: 4000 }) + return + } + toast(`Update v${s.version} available`, { + action: { label: "View", onClick: () => openDialog?.() }, + duration: 10000, + }) +} + +function fireDownloaded(s: UpdateStatus): void { + toast.success(`Update v${s.version} ready`, { + duration: Infinity, + action: { + label: "Restart and Update", + onClick: () => { + installUpdate().catch(() => { + toast.error("Failed to start update. Try restarting the app manually.") + }) + }, + }, + }) +} + +function fireError(s: UpdateStatus): void { + if (!userInitiated) return + if (s.code === "dev-mode") return + toast.error(`Update check failed: ${s.error ?? "unknown error"}`, { + action: { label: "Retry", onClick: () => openDialog?.() }, + }) + userInitiated = false +} + +function fireNotAvailable(): void { + if (!userInitiated) return + toast.success("You're on the latest version.") + userInitiated = false +} + +export function initUpdateToasts(getAutoDownload: () => boolean): () => void { + return subscribe((s) => { + const key = dedupeKey(s) + if (key === lastKey) return + lastKey = key + + if (s.state === "available") fireAvailable(s, getAutoDownload()) + else if (s.state === "downloaded") fireDownloaded(s) + else if (s.state === "error") fireError(s) + else if (s.state === "not-available") fireNotAvailable() + }) +} diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 5c13a7b8f..210cd3ab8 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -291,6 +291,22 @@ export async function installUpdate(): Promise { return invoke("install_update") } +export async function downloadUpdate(): Promise { + return invoke("download_update") +} + +export async function getAppVersion(): Promise { + return invoke("get_app_version") +} + +export async function getAutoDownload(): Promise { + return invoke("get_auto_download") +} + +export async function setAutoDownload(enabled: boolean): Promise { + return invoke("set_auto_download", { enabled }) +} + // Analytics export function analyticsTrack( name: string, diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index 24d683efe..69dfa503a 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -8,12 +8,37 @@ import type { import { listen } from "./bridge.js" import type { UnlistenFn } from "./types.js" +export type UpdateStateValue = + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded" + | "not-available" + | "error" + +export type UpdateErrorCode = + | "dev-mode" + | "unsupported" + | "network" + | "feed-error" + | "verification" + +export interface UpdateProgress { + percent: number + bytesPerSecond: number + transferred: number + total: number +} + export interface UpdateStatus { - state: "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error" + state: UpdateStateValue version?: string releaseNotes?: string releaseName?: string + progress?: UpdateProgress error?: string + code?: UpdateErrorCode } export const EVENT_NAMES = { diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index 34bbe7944..21786f9dc 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -1,4 +1,5 @@ import { writable } from "svelte/store" +import { getAutoDownload, setAutoDownload } from "$lib/ipc/commands.js" const browser = typeof window !== "undefined" @@ -132,14 +133,43 @@ export function setSidebarPosition(value: SidebarPosition) { sidebarPosition.set(value) } -// Auto-update +// Auto-update — main process owns the persistent value. localStorage +// is a cache for instant first paint; `syncAutoUpdateFromMain` reconciles +// it with the main-process truth at app boot. export const autoUpdate = writable( getStoredBool(AUTO_UPDATE_KEY, true), ) -export function setAutoUpdate(value: boolean) { +// Tracks whether the user has toggled the auto-update setting since boot. +// If so, syncAutoUpdateFromMain skips its store update so the user's +// in-flight choice is not clobbered by a slow IPC round-trip. +let userTouchedAutoUpdate = false + +export async function syncAutoUpdateFromMain(): Promise { + try { + const value = await getAutoDownload() + if (userTouchedAutoUpdate) return + if (browser) localStorage.setItem(AUTO_UPDATE_KEY, String(value)) + autoUpdate.set(value) + } catch (err) { + console.warn("[settings] getAutoDownload failed; keeping cached value:", err) + } +} + +export async function setAutoUpdate(value: boolean): Promise { + userTouchedAutoUpdate = true + // Optimistic local update for instant feedback. + const previous = getStoredBool(AUTO_UPDATE_KEY, true) if (browser) localStorage.setItem(AUTO_UPDATE_KEY, String(value)) autoUpdate.set(value) + try { + await setAutoDownload(value) + } catch (err) { + // Rollback so UI and main process stay aligned on IPC failure. + if (browser) localStorage.setItem(AUTO_UPDATE_KEY, String(previous)) + autoUpdate.set(previous) + console.warn("[settings] setAutoDownload failed; rolled back:", err) + } } // Default IDE diff --git a/desktop/src/renderer/src/lib/stores/updates.svelte.ts b/desktop/src/renderer/src/lib/stores/updates.svelte.ts new file mode 100644 index 000000000..66cf8da34 --- /dev/null +++ b/desktop/src/renderer/src/lib/stores/updates.svelte.ts @@ -0,0 +1,66 @@ +import { onUpdateStatus, type UpdateStatus } from "$lib/ipc/events.js" +import type { UnlistenFn } from "$lib/ipc/types.js" + +type Listener = (s: UpdateStatus) => void + +const state: { current: UpdateStatus; lastCheckedAt: number | null } = $state({ + current: { state: "idle" }, + lastCheckedAt: null, +}) + +const listeners: Listener[] = [] +let unlisten: UnlistenFn | null = null + +export function updateStatus(): UpdateStatus { + return state.current +} + +export function lastCheckedAt(): number | null { + return state.lastCheckedAt +} + +export function hasUpdate(): boolean { + const s = state.current.state + return s === "available" || s === "downloading" || s === "downloaded" +} + +export function isReady(): boolean { + return state.current.state === "downloaded" +} + +export function isChecking(): boolean { + return state.current.state === "checking" +} + +export function isDevMode(): boolean { + return state.current.state === "not-available" && state.current.code === "dev-mode" +} + +export function subscribe(fn: Listener): () => void { + listeners.push(fn) + return () => { + const i = listeners.indexOf(fn) + if (i >= 0) listeners.splice(i, 1) + } +} + +function set(next: UpdateStatus): void { + state.current = next + if (next.state === "not-available" || next.state === "available") { + state.lastCheckedAt = Date.now() + } + for (const fn of listeners) fn(next) +} + +export async function initUpdateStore(): Promise { + if (unlisten) return + unlisten = await onUpdateStatus(set) +} + +export function disposeUpdateStore(): void { + unlisten?.() + unlisten = null +} + +// Test-only setter +export const __setForTest = set diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index ee49d14b1..1e2550eb6 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -1,6 +1,5 @@