Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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:
Expand Down
21 changes: 20 additions & 1 deletion desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
},
"dependencies": {
"chokidar": "^4.0.0",
"dompurify": "^3.4.7",
"electron-updater": "^6.8.3",
"node-pty": "^1.0.0",
"posthog-node": "^5.34.2",
"svelte-spa-router": "^5.0.1"
},
"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",
Expand Down
36 changes: 36 additions & 0 deletions desktop/src/main/__tests__/tray.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
})
})
98 changes: 98 additions & 0 deletions desktop/src/main/__tests__/updater.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, (...args: unknown[]) => 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" }),
)
})
Comment on lines +46 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard mutable mock restoration with try/finally.

Line 44 mutates shared mock state; if the test exits early, isPackaged may stay false and contaminate later tests. Wrap the mutation in try/finally to guarantee restoration.

Suggested fix
   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" }),
-    )
-    ;(electron.app as { isPackaged: boolean }).isPackaged = true
+    ;(electron.app as { isPackaged: boolean }).isPackaged = false
+    try {
+      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" }),
+      )
+    } finally {
+      ;(electron.app as { isPackaged: boolean }).isPackaged = true
+    }
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@desktop/src/main/__tests__/updater.test.ts` around lines 42 - 54, The test
mutates shared state electron.app.isPackaged; wrap the mutation and test logic
in a try/finally so isPackaged is always restored. Specifically, in the "emits
dev-mode status when app is not packaged" test, set (electron.app as {
isPackaged: boolean }).isPackaged = false, then run the initAutoUpdater() call
and assertions inside a try block, and in the finally block reset (electron.app
as { isPackaged: boolean }).isPackaged = true to avoid leaking state across
tests (references: initAutoUpdater, electron.app.isPackaged,
send/webContents.send).


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<typeof vi.fn>)).not.toHaveBeenCalled()
})
})
40 changes: 37 additions & 3 deletions desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
},
)

Expand All @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/main/tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +102,9 @@ export class AppTray {
: `${count} workspace${count === 1 ? "" : "s"}`

const template: Electron.MenuItemConstructorOptions[] = [
...buildUpdateMenuItems(getLastStatus(), () => {
installUpdate().catch(() => {})
}),
{ label: statusLabel, enabled: false },
]

Expand Down
Loading
Loading