Skip to content

feat(desktop): redesign update UX#459

Merged
skevetter merged 1 commit into
mainfrom
improve-update-ux
May 29, 2026
Merged

feat(desktop): redesign update UX#459
skevetter merged 1 commit into
mainfrom
improve-update-ux

Conversation

@skevetter

@skevetter skevetter commented May 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the broken auto-updater (manifest URLs pointed to a CDN that never had binaries) and ships a redesigned, coordinated update UX. Delivered as one PR per request.

Root cause of the failed update check

The error users were seeing — "Auto-update is not available (app is not code-signed)" — was a misleading catch-all. Code signing was fine. Two real bugs were causing the failures:

  1. Manifests on dl.devsy.sh listed relative URLs like Devsy_mac_arm64.zip, but the binaries lived only on GitHub Releases. electron-updater fetched the manifest fine, then 404'd on every binary download.
  2. hack/merge_mac_metadata.go accumulated stale duplicate files[] entries because each release re-merged the deployed manifest with the new one. The deployed beta-mac.yml listed Devsy_mac_arm64.zip three times with three different sha512 hashes; electron-updater happened to pick a stale one.

What changed

Publish pipeline (hack/ + .github/workflows/release.yml)

  • New hack/rewrite_manifest_urls/ Go helper rewrites manifest files[].url and path to absolute https://github.com/<owner>/<repo>/releases/download/<tag>/<file> URLs. Binaries stay on GitHub Releases (download stats preserved); manifests on the CDN now point to them.
  • hack/merge_mac_metadata/ (moved into subdir for layout consistency) now dedupes by URL with last-write-wins.
  • Dropped the "fetch existing metadata from live site" step that was the source of stale entries.
  • New hack/smoke_test_manifests/ Go helper HEAD-checks every URL in every deployed manifest before Netlify deploy, with GITHUB_TOKEN auth for github.com hosts. Future regressions of this class fail the release job loudly instead of silently breaking users.

Main process (desktop/src/main/updater.ts)

  • Extended UpdateStatus with progress, code (dev-mode/unsupported/network/feed-error/verification), and an idle state.
  • Wired download-progress events to the renderer.
  • Replaced the misleading code-signed message with real error codes.
  • Removed the native dialog.showMessageBox on update-downloaded — the renderer owns this UX now.
  • Honors the user's auto-download setting end-to-end via new set_auto_download / get_auto_download IPC.

Renderer UX

  • New global Svelte 5 store at $lib/stores/updates.svelte.ts; subscribed once at App root.
  • New UpdateDialog.svelte modal renders all six states (checking / available / downloading-with-progress-bar / downloaded-with-restart-CTA / not-available / error). Built on bits-ui Dialog.
  • New update-toasts.ts fires svelte-sonner toasts for available / downloaded / error / not-available, gated on user-initiated checks so background errors stay silent.
  • New UpdateBadge.svelte passive header indicator (appears only when an update is in flight).
  • Tray menu shows "Install Update v…" item when ready.
  • Settings page slimmed: removed the "Install Specific Version" CLI pin block, fixed the version row to show app.getVersion(), delegated inline status to the new dialog via a shared openUpdateDialog() helper.

Summary by CodeRabbit

  • New Features

    • Added update notification badge in the header displaying availability and download progress.
    • Introduced a new update dialog showing release notes, progress tracking, and download/install actions.
    • Added automatic update download toggle in the settings.
    • Enabled stable and beta release channel selection.
    • Enhanced toast notifications for update events and status changes.
    • Added application version display in settings.
  • Tests

    • Expanded test coverage for update functionality, dialogs, and manifest handling.

Review Change Stack

@netlify

netlify Bot commented May 29, 2026

Copy link
Copy Markdown

Deploy Preview for devsydev canceled.

Name Link
🔨 Latest commit c1f52e5
🔍 Latest deploy log https://app.netlify.com/projects/devsydev/deploys/6a1a0e09aa7d9d000851d604

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@skevetter, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 17 minutes and 19 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0a578e0d-68d3-4469-b437-2a8780c525cd

📥 Commits

Reviewing files that changed from the base of the PR and between 5887adc and c1f52e5.

⛔ Files ignored due to path filters (1)
  • desktop/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (25)
  • .github/workflows/release.yml
  • desktop/package.json
  • desktop/src/main/__tests__/tray.test.ts
  • desktop/src/main/__tests__/updater.test.ts
  • desktop/src/main/ipc.ts
  • desktop/src/main/tray.ts
  • desktop/src/main/updater.ts
  • desktop/src/renderer/src/App.svelte
  • desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte
  • desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte
  • desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts
  • desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte
  • desktop/src/renderer/src/lib/components/update/update-toasts.test.ts
  • desktop/src/renderer/src/lib/components/update/update-toasts.ts
  • desktop/src/renderer/src/lib/ipc/commands.ts
  • desktop/src/renderer/src/lib/ipc/events.ts
  • desktop/src/renderer/src/lib/stores/settings.ts
  • desktop/src/renderer/src/lib/stores/updates.svelte.ts
  • desktop/src/renderer/src/pages/SettingsPage.svelte
  • hack/merge_mac_metadata/main.go
  • hack/merge_mac_metadata/main_test.go
  • hack/rewrite_manifest_urls/main.go
  • hack/rewrite_manifest_urls/main_test.go
  • hack/smoke_test_manifests/main.go
  • hack/smoke_test_manifests/main_test.go
📝 Walkthrough

Walkthrough

This PR implements a complete desktop update system for the Electron app, spanning main-process updater state management with persistent settings, renderer UI components for user-facing update flows, IPC bridging for main/renderer communication, and build-time tooling for manifest validation and rewriting. It refactors update handling into a structured event model with progress tracking and error classification.

Changes

Update System Core & Renderer Integration

Layer / File(s) Summary
Update Status Types & IPC Contract
desktop/src/renderer/src/lib/ipc/events.ts
UpdateStateValue union, UpdateErrorCode classifiers, and UpdateProgress metrics establish the shared type contract; UpdateStatus now includes structured error codes and progress data.
Main-Process Update State Management & Event Wiring
desktop/src/main/updater.ts
Core updater refactored with lastStatus caching, settings persistence (channel + auto-download), error classification via classifyError, electron-updater event wiring to setStatus (broadcast to renderer), and a shared getUpdater() helper for availability checks.
IPC Handlers, Auto-Download Settings, & Tray Menu Integration
desktop/src/main/ipc.ts, desktop/src/main/tray.ts
New IPC handlers expose downloadUpdate, getAppVersion, getAutoDownload, setAutoDownload; buildUpdateMenuItems generates tray menu entries for "downloaded" state update installation.
Renderer Update Store & Settings Synchronization
desktop/src/renderer/src/lib/stores/updates.svelte.ts, desktop/src/renderer/src/lib/stores/settings.ts
New updates.svelte.ts reactive store with listener subscriptions and query helpers; syncAutoUpdateFromMain() and refactored setAutoUpdate() bidirectionally sync auto-download setting with main process.
Update UI Components (Badge, Dialog, Settings Section)
desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte, UpdateDialog.svelte, UpdatesSection.svelte
UpdateBadge (header icon + progress), UpdateDialog (multi-state flow with sanitized release notes, auto/manual download), UpdatesSection (version display, channel selection, status messaging).
Update Toast Notifications & App Lifecycle Wiring
desktop/src/renderer/src/lib/components/update/update-toasts.ts, desktop/src/renderer/src/App.svelte, desktop/src/renderer/src/pages/SettingsPage.svelte
Toast-driven notifications for update states (available/info, downloaded/action, error/retry); App.svelte initializes store/toasts/dialog; SettingsPage delegates update UI to UpdatesSection component.
Renderer IPC Command Wrappers
desktop/src/renderer/src/lib/ipc/commands.ts
Four new IPC command exports: downloadUpdate(), getAppVersion(), getAutoDownload(), setAutoDownload(enabled).

Test Coverage (Main Process & Renderer)

Layer / File(s) Summary
Main Process Tests: Updater & Tray
desktop/src/main/__tests__/tray.test.ts, desktop/src/main/__tests__/updater.test.ts
Tests for buildUpdateMenuItems (empty for non-downloaded, install item + separator when downloaded); initAutoUpdater behavior under dev-mode, download-progress emission, auto-download toggle, and update-downloaded handling.
Renderer Tests: UpdateDialog & Update Toasts
desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts, update-toasts.test.ts
UpdateDialog renders correct UI per state (checking, downloaded, downloading, error, not-available); update-toasts deduplicates by (state, version), gates error toasts to user-initiated flows, suppresses dev-mode errors, and respects auto-download setting.

Build Tools: Manifest Management & Release Pipeline

Layer / File(s) Summary
Manifest URL Rewriting Tool & Tests
hack/rewrite_manifest_urls/main.go, main_test.go
Walks directory for latest*.yml/beta*.yml files, rewrites relative files[].url and path to absolute GitHub Release URLs; tests verify relative URL rewriting, absolute URL preservation, non-map file-entry handling, and filename filtering.
Smoke Test Tool & Tests
hack/smoke_test_manifests/main.go, main_test.go
Validates manifest URLs via HTTP HEAD requests (with GitHub token auth for github.com hosts); collects URLs from files[].url and top-level path; tests cover URL collection, successful/failed reachability, relative URL rejection, and missing-directory tolerance.
macOS Metadata Merge URL Deduplication & Tests
hack/merge_mac_metadata/main.go, main_test.go
Deduplicates manifest file entries by URL using last-write-wins; defensive handling for non-map entries; tests verify deduplication across architectures, empty files preservation, entries without URLs, and single-source ordering.
Release Workflow & Dependency Updates
.github/workflows/release.yml, desktop/package.json
deploy-update-metadata now runs manifest rewrite and smoke-test steps; macOS merge step updated to use hack/merge_mac_metadata/main.go; adds @types/dompurify and updates dompurify to ^3.4.7.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

  • devsy-org/devsy#423: Modifies hack/merge_mac_metadata tooling and updates the release workflow to invoke the Go implementation instead of a prior script.
  • devsy-org/devsy#391: Updates availability guards for updater functions (initAutoUpdater, checkForUpdates, installUpdate) that directly overlap with this PR's refactored desktop/src/main/updater.ts.
  • devsy-org/devsy#390: Extends the release pipeline around beta/stable metadata from split macOS architecture builds, complementing this PR's manifest rewriting and smoke-test additions.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(desktop): redesign update UX' accurately summarizes the main change—a comprehensive redesign of the desktop application's update user experience, including both backend pipeline improvements and frontend UI changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

⚠️ This PR contains unsigned commits. To get your PR merged, please sign those commits (git rebase --exec 'git commit -S --amend --no-edit -n' @{upstream}) and force push them to this branch (git push --force-with-lease).

If you're new to commit signing, there are different ways to set it up:

Sign commits with gpg

Follow the steps below to set up commit signing with gpg:

  1. Generate a GPG key
  2. Add the GPG key to your GitHub account
  3. Configure git to use your GPG key for commit signing
Sign commits with ssh-agent

Follow the steps below to set up commit signing with ssh-agent:

  1. Generate an SSH key and add it to ssh-agent
  2. Add the SSH key to your GitHub account
  3. Configure git to use your SSH key for commit signing
Sign commits with 1Password

You can also sign commits using 1Password, which lets you sign commits with biometrics without the signing key leaving the local 1Password process.

Learn how to use 1Password to sign your commits.

Watch the demo

@skevetter skevetter force-pushed the improve-update-ux branch from fe90f01 to 5887adc Compare May 29, 2026 21:05
@skevetter skevetter changed the title fix(desktop): repair auto-update pipeline and redesign update UX feat(desktop): redesign update UX May 29, 2026
@skevetter skevetter marked this pull request as ready for review May 29, 2026 21:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (1)
hack/smoke_test_manifests/main_test.go (1)

39-75: ⚡ Quick win

Assert that the probe stays on HEAD.

These tests validate status handling, but they would still pass if checkURL regressed to GET. Since the tool’s contract is specifically HEAD-based, make the handlers reject other methods.

Suggested test hardening
 func TestRunSucceedsWhenAllReachable(t *testing.T) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodHead {
+			t.Fatalf("expected HEAD request, got %s", r.Method)
+		}
 		w.WriteHeader(200)
 	}))
 	defer srv.Close()
@@
 func TestRunFailsOn404(t *testing.T) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodHead {
+			t.Fatalf("expected HEAD request, got %s", r.Method)
+		}
 		w.WriteHeader(404)
 	}))
 	defer srv.Close()
🤖 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 `@hack/smoke_test_manifests/main_test.go` around lines 39 - 75, The tests
currently accept any HTTP method, hiding regressions if checkURL uses GET;
update the handlers inside TestRunSucceedsWhenAllReachable and TestRunFailsOn404
to assert r.Method == "HEAD" (or return 405 for non-HEAD) and only then respond
with the intended status code so the tests fail if the probe switches from HEAD
to GET; reference the test functions TestRunSucceedsWhenAllReachable and
TestRunFailsOn404 and the server handlers to locate where to add the method
check.
🤖 Prompt for all review comments with 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.

Inline comments:
In @.github/workflows/release.yml:
- Around line 213-218: The run step that calls "go run
hack/rewrite_manifest_urls/main.go desktop/release/ \"${{ github.repository }}\"
\"${{ inputs.tag || github.ref_name }}\"" interpolates
inputs.tag/github.ref_name directly into the shell and risks command injection;
change the step to pass the tag/ref via the job environment (env:) and reference
the env variable inside run (e.g., use an ENV var like INPUT_TAG or REF_NAME) so
the value is treated as data, not shell code—update the workflow step that
invokes the rewrite_manifest_urls program to set the tag via env and use that
env var in the run command instead of the direct ${{ ... }} expression.

In `@desktop/package.json`:
- Around line 24-26: Move the "`@types/dompurify`" entry out of the dependencies
block and into devDependencies in desktop/package.json: remove the
"`@types/dompurify`": "^3.0.5" line from the "dependencies" object and add the
same version under "devDependencies"; after updating package.json, run your
package manager (npm/yarn/pnpm) to update the lockfile so the change is
reflected in installs.

In `@desktop/src/main/__tests__/updater.test.ts`:
- Around line 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).

In `@desktop/src/main/ipc.ts`:
- Around line 793-795: Handler persists the new release channel before awaiting
checkForUpdatesWithChannel, which can leave persisted state out of sync if the
check fails; change the flow to call await
checkForUpdatesWithChannel(args.channel) first and only call
setReleaseChannel(channel) after that succeeds, or wrap the current sequence in
try/catch and on failure revert persistence by calling
setReleaseChannel(previousChannel); refer to the handler's use of args.channel,
setReleaseChannel and checkForUpdatesWithChannel to implement the reorder or the
rollback logic so UI and persisted channel never diverge.

In `@desktop/src/main/updater.ts`:
- Around line 177-186: The download-progress handler currently calls setStatus
with a new object that overwrites existing metadata (version, releaseName,
releaseNotes); change it to merge into the existing status instead of replacing
it — e.g. in the autoUpdater.on("download-progress", ...) callback use a
functional update to setStatus(prev => ({ ...prev, state: "downloading",
progress: { percent: info.percent, bytesPerSecond: info.bytesPerSecond,
transferred: info.transferred, total: info.total } })) so
version/releaseName/releaseNotes are preserved during download.
- Around line 116-119: setAutoDownloadEnabled currently only updates the
in-memory flag and persisted settings but does not change the live updater
instance; after initAutoUpdater runs the autoUpdater.autoDownload remains stale
until restart. Modify setAutoDownloadEnabled(enabled: boolean) to also set the
running updater's autoDownload property when available (e.g., if a module-level
autoUpdater or the object returned by initAutoUpdater exists), so call something
like autoUpdater.autoDownload = enabled (guarded by a null/undefined check) in
addition to updating autoDownloadEnabled and saveSettings({ autoDownload:
enabled }). Ensure you reference the existing symbols setAutoDownloadEnabled,
autoDownloadEnabled, saveSettings, and the runtime autoUpdater/initAutoUpdater
variable so the change applies immediately when toggled.

In `@desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte`:
- Around line 19-26: The badge currently treats any non-ready state as
"downloading", causing the pulsing Download icon and a 0% progress when s.state
=== "available"; update the rendering logic in UpdateBadge.svelte to distinguish
s.state === "downloading" from s.state === "available" (and other states), e.g.
use the existing ready boolean plus explicit checks on s.state to: 1) show the
Download icon and progress only when s.state === "downloading", 2) show a
separate icon/text (e.g. an "available" label without progress or pulsing) when
s.state === "available", and 3) update the title string (title={...}) to reflect
the specific state ("available" vs "downloading") so tooltips are accurate.

In `@desktop/src/renderer/src/lib/stores/settings.ts`:
- Around line 159-165: The renderer updates localStorage and the autoUpdate
store before the IPC write in setAutoDownload completes, causing the UI to show
a value the main process may not have applied; change setAutoUpdate to await
setAutoDownload (or handle its Promise) and only set userTouchedAutoUpdate,
localStorage.setItem(AUTO_UPDATE_KEY, String(value)) and autoUpdate.set(value)
after setAutoDownload resolves successfully; on rejection, revert any
provisional UI changes (or keep prior state) and surface/log the error so the
renderer and main process stay consistent; reference the setAutoUpdate function,
the setAutoDownload call, userTouchedAutoUpdate, AUTO_UPDATE_KEY, and autoUpdate
store when making the change.

In `@desktop/src/renderer/src/lib/stores/updates.svelte.ts`:
- Around line 39-45: subscribe currently only registers future listeners, so any
update stored in state.current before a subscriber (e.g., initUpdateToasts after
initUpdateStore) never gets replayed; modify subscribe (the function that pushes
into listeners) to immediately invoke the new listener with the current cached
state (state.current) after adding it so subscribers receive the latest status
right away, while keeping the existing unsubscribe closure logic unchanged.

In `@hack/rewrite_manifest_urls/main_test.go`:
- Around line 10-39: The test TestRewriteRewritesRelativeURLs only checks that
files[].url were rewritten but not the top-level path; update the test to
explicitly assert that the top-level "path" field was rewritten to the absolute
GitHub release URL after calling rewriteFile (use the same expected URL pattern
as for files[].url). Locate TestRewriteRewritesRelativeURLs and after reading
got (from os.ReadFile) add an assertion that string(got) contains the expected
rewritten path URL (constructed the same way as want) so the rewriteFile(path,
repo, tag) behavior for the top-level path is verified.

---

Nitpick comments:
In `@hack/smoke_test_manifests/main_test.go`:
- Around line 39-75: The tests currently accept any HTTP method, hiding
regressions if checkURL uses GET; update the handlers inside
TestRunSucceedsWhenAllReachable and TestRunFailsOn404 to assert r.Method ==
"HEAD" (or return 405 for non-HEAD) and only then respond with the intended
status code so the tests fail if the probe switches from HEAD to GET; reference
the test functions TestRunSucceedsWhenAllReachable and TestRunFailsOn404 and the
server handlers to locate where to add the method check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 77c7f655-4418-472b-91d6-a653564fb524

📥 Commits

Reviewing files that changed from the base of the PR and between 7c64ce2 and 5887adc.

⛔ Files ignored due to path filters (1)
  • desktop/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (25)
  • .github/workflows/release.yml
  • desktop/package.json
  • desktop/src/main/__tests__/tray.test.ts
  • desktop/src/main/__tests__/updater.test.ts
  • desktop/src/main/ipc.ts
  • desktop/src/main/tray.ts
  • desktop/src/main/updater.ts
  • desktop/src/renderer/src/App.svelte
  • desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte
  • desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte
  • desktop/src/renderer/src/lib/components/update/UpdateDialog.test.ts
  • desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte
  • desktop/src/renderer/src/lib/components/update/update-toasts.test.ts
  • desktop/src/renderer/src/lib/components/update/update-toasts.ts
  • desktop/src/renderer/src/lib/ipc/commands.ts
  • desktop/src/renderer/src/lib/ipc/events.ts
  • desktop/src/renderer/src/lib/stores/settings.ts
  • desktop/src/renderer/src/lib/stores/updates.svelte.ts
  • desktop/src/renderer/src/pages/SettingsPage.svelte
  • hack/merge_mac_metadata/main.go
  • hack/merge_mac_metadata/main_test.go
  • hack/rewrite_manifest_urls/main.go
  • hack/rewrite_manifest_urls/main_test.go
  • hack/smoke_test_manifests/main.go
  • hack/smoke_test_manifests/main_test.go

Comment thread .github/workflows/release.yml Outdated
Comment thread desktop/package.json Outdated
Comment on lines +42 to +54
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
})

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).

Comment thread desktop/src/main/ipc.ts Outdated
Comment thread desktop/src/main/updater.ts
Comment thread desktop/src/main/updater.ts
Comment thread desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte Outdated
Comment thread desktop/src/renderer/src/lib/stores/settings.ts Outdated
Comment on lines +39 to +45
export function subscribe(fn: Listener): () => void {
listeners.push(fn)
return () => {
const i = listeners.indexOf(fn)
if (i >= 0) listeners.splice(i, 1)
}
}

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate and inspect the update store implementation
fd -a "updates\.svelte\.ts" -0 . | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,120p" {}'

# 2) Find where subscribe is used and where initUpdateToasts / initUpdateStore are called
rg -n "initUpdateToasts|initUpdateStore|subscribe\(" desktop/src/renderer/src -S || true

# 3) Inspect App.svelte around the update wiring
fd -a "App\.svelte" desktop/src/renderer/src -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; rg -n "initUpdateToasts|initUpdateStore|update" {} -n -S; sed -n "1,220p" {}' || true

# 4) Inspect update-toasts implementation
fd -a "update-toasts\.ts" desktop/src/renderer/src -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,240p" {}' || true

# 5) Search for state.current usage and Listener type
rg -n "state\.current|type Listener|interface Listener|Listener" desktop/src/renderer/src/lib/stores -S || true

Repository: devsy-org/devsy

Length of output: 15440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect update-toasts tests
fd -a "update-toasts\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,220p" {}'

# Inspect UpdateDialog tests that might cover initialization ordering
fd -a "UpdateDialog\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,240p" {}'

# Inspect mock subscribe in update-toasts.test
rg -n "subscribe\\(fn" desktop/src/renderer/src/lib/components/update/update-toasts.test.ts

Repository: devsy-org/devsy

Length of output: 7007


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect update-toasts tests
fd -a "update-toasts\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,240p" {}'

# Inspect UpdateDialog tests
fd -a "UpdateDialog\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,220p" {}'

# Search for other subscribe usages of updates store
rg -n "subscribe\\(" desktop/src/renderer/src/lib -S | rg -n "stores/updates\\.svelte" || true

Repository: devsy-org/devsy

Length of output: 7077


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect update-toasts tests
fd -a "update-toasts\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,260p" {}'

# Inspect UpdateDialog tests
fd -a "UpdateDialog\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,240p" {}'

# Search for other subscribers to updates store
rg -n "from \"\\$lib/stores/updates\\.svelte" desktop/src/renderer/src/lib -S || true
rg -n "subscribe\\(" desktop/src/renderer/src/lib -S | rg -n "updates\\.svelte" || true

Repository: devsy-org/devsy

Length of output: 7006


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Update-toasts tests
fd -a "update-toasts\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,260p" {}'

# UpdateDialog tests
fd -a "UpdateDialog\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,240p" {}'

# Any other subscribers using updates.svelte
rg -n "stores/updates\.svelte" desktop/src/renderer/src/lib -S || true

Repository: devsy-org/devsy

Length of output: 7734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Update-toasts tests
fd -a "update-toasts\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,260p" {}'

# UpdateDialog tests
fd -a "UpdateDialog\.test\.ts" desktop/src/renderer/src/lib/components/update -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; sed -n "1,240p" {}'

# Any other subscribers using updates.svelte
rg -n "stores/updates\.svelte" desktop/src/renderer/src/lib -S || true

Repository: devsy-org/devsy

Length of output: 7734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect components that read updateStatus
for f in desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte \
         desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte \
         desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte; do
  echo "---- $f"
  sed -n '1,220p' "$f" | nl -ba | head -n 220
done

# Inspect UpdateDialog.svelte to see how it uses updateStatus
sed -n '1,260p' desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte | nl -ba | head -n 260

Repository: devsy-org/devsy

Length of output: 191


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect components that read updateStatus
for f in \
  desktop/src/renderer/src/lib/components/update/UpdateBadge.svelte \
  desktop/src/renderer/src/lib/components/update/UpdatesSection.svelte \
  desktop/src/renderer/src/lib/components/update/UpdateDialog.svelte; do
  echo "---- $f"
  sed -n '1,240p' "$f" | cat -n | head -n 260
done

Repository: devsy-org/devsy

Length of output: 12670


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and print ipc/events.js (or specific onUpdateStatus implementation)
fd -a "events\.js$" desktop/src/renderer/src -0 | head -n 20 | cat

fd -a "events\.js" desktop/src/renderer/src/lib/ipc -0 | xargs -0 -I{} bash -lc 'echo "---- {}"; rg -n "onUpdateStatus" -n {} && sed -n "1,220p" {}'

# Also search for onUpdateStatus usage
rg -n "onUpdateStatus" desktop/src/renderer/src -S

Repository: devsy-org/devsy

Length of output: 490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' desktop/src/renderer/src/lib/ipc/events.ts | cat -n

# Print only onUpdateStatus region
rg -n "export function onUpdateStatus" -n desktop/src/renderer/src/lib/ipc/events.ts
start=$(rg -n "export function onUpdateStatus" desktop/src/renderer/src/lib/ipc/events.ts | head -n1 | cut -d: -f1)
# Grab 120 lines after start
sed -n "$start,$((start+160))p" desktop/src/renderer/src/lib/ipc/events.ts | cat -n

Repository: devsy-org/devsy

Length of output: 3819


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "subscribe\\([^\\)]*\\)" desktop/src/renderer/src/lib -S | rg -n "stores/updates\\.svelte\\.js|stores/updates\\.svelte" || true
rg -n "\\bsubscribe\\b" desktop/src/renderer/src/lib | rg -n "updates\\.svelte" || true
rg -n "from \"\\$lib/stores/updates\\.svelte\\.js\"" desktop/src/renderer/src/lib -S || true

Repository: devsy-org/devsy

Length of output: 470


Replay cached update status to new subscribers in updates.svelte.ts

subscribe() only notifies future set() calls. With App.svelte awaiting initUpdateStore() before wiring initUpdateToasts(), an "available"/"downloaded" event received in that gap updates state.current but never reaches the toast subscriber, so the one-shot toast/restart UX can be missed.

♻️ Proposed fix
 export function subscribe(fn: Listener): () => void {
   listeners.push(fn)
+  fn(state.current)
   return () => {
     const i = listeners.indexOf(fn)
     if (i >= 0) listeners.splice(i, 1)
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function subscribe(fn: Listener): () => void {
listeners.push(fn)
return () => {
const i = listeners.indexOf(fn)
if (i >= 0) listeners.splice(i, 1)
}
}
export function subscribe(fn: Listener): () => void {
listeners.push(fn)
fn(state.current)
return () => {
const i = listeners.indexOf(fn)
if (i >= 0) listeners.splice(i, 1)
}
}
🤖 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/renderer/src/lib/stores/updates.svelte.ts` around lines 39 - 45,
subscribe currently only registers future listeners, so any update stored in
state.current before a subscriber (e.g., initUpdateToasts after initUpdateStore)
never gets replayed; modify subscribe (the function that pushes into listeners)
to immediately invoke the new listener with the current cached state
(state.current) after adding it so subscribers receive the latest status right
away, while keeping the existing unsubscribe closure logic unchanged.

Comment on lines +10 to +39
func TestRewriteRewritesRelativeURLs(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "beta-mac.yml")
yaml := `version: 1.10.0-beta.12
files:
- url: Devsy_mac_arm64.zip
sha512: abc==
size: 100
- url: Devsy_mac_x64.dmg
sha512: def==
size: 200
path: Devsy_mac_arm64.zip
sha512: abc==
size: 100
`
if err := os.WriteFile(in, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
if err := rewriteFile(in, "devsy-ai/devsy", "v1.10.0-beta.12"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(in)
want := "https://github.com/devsy-ai/devsy/releases/download/v1.10.0-beta.12/Devsy_mac_arm64.zip"
if !strings.Contains(string(got), want) {
t.Fatalf("missing rewritten url. got:\n%s", got)
}
if !strings.Contains(string(got), "Devsy_mac_x64.dmg") {
t.Fatal("second url missing")
}
}

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

Assert the top-level path rewrite explicitly.

This test currently passes if only files[].url is rewritten, but the updater path regression also depends on path becoming absolute. Add a direct assertion for the path: field so the main failure mode stays covered.

Suggested test tightening
 	got, _ := os.ReadFile(in)
 	want := "https://github.com/devsy-ai/devsy/releases/download/v1.10.0-beta.12/Devsy_mac_arm64.zip"
 	if !strings.Contains(string(got), want) {
 		t.Fatalf("missing rewritten url. got:\n%s", got)
 	}
+	if !strings.Contains(string(got), "path: "+want) {
+		t.Fatalf("top-level path was not rewritten. got:\n%s", got)
+	}
 	if !strings.Contains(string(got), "Devsy_mac_x64.dmg") {
 		t.Fatal("second url missing")
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestRewriteRewritesRelativeURLs(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "beta-mac.yml")
yaml := `version: 1.10.0-beta.12
files:
- url: Devsy_mac_arm64.zip
sha512: abc==
size: 100
- url: Devsy_mac_x64.dmg
sha512: def==
size: 200
path: Devsy_mac_arm64.zip
sha512: abc==
size: 100
`
if err := os.WriteFile(in, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
if err := rewriteFile(in, "devsy-ai/devsy", "v1.10.0-beta.12"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(in)
want := "https://github.com/devsy-ai/devsy/releases/download/v1.10.0-beta.12/Devsy_mac_arm64.zip"
if !strings.Contains(string(got), want) {
t.Fatalf("missing rewritten url. got:\n%s", got)
}
if !strings.Contains(string(got), "Devsy_mac_x64.dmg") {
t.Fatal("second url missing")
}
}
func TestRewriteRewritesRelativeURLs(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "beta-mac.yml")
yaml := `version: 1.10.0-beta.12
files:
- url: Devsy_mac_arm64.zip
sha512: abc==
size: 100
- url: Devsy_mac_x64.dmg
sha512: def==
size: 200
path: Devsy_mac_arm64.zip
sha512: abc==
size: 100
`
if err := os.WriteFile(in, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
if err := rewriteFile(in, "devsy-ai/devsy", "v1.10.0-beta.12"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(in)
want := "https://github.com/devsy-ai/devsy/releases/download/v1.10.0-beta.12/Devsy_mac_arm64.zip"
if !strings.Contains(string(got), want) {
t.Fatalf("missing rewritten url. got:\n%s", got)
}
if !strings.Contains(string(got), "path: "+want) {
t.Fatalf("top-level path was not rewritten. got:\n%s", got)
}
if !strings.Contains(string(got), "Devsy_mac_x64.dmg") {
t.Fatal("second url missing")
}
}
🤖 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 `@hack/rewrite_manifest_urls/main_test.go` around lines 10 - 39, The test
TestRewriteRewritesRelativeURLs only checks that files[].url were rewritten but
not the top-level path; update the test to explicitly assert that the top-level
"path" field was rewritten to the absolute GitHub release URL after calling
rewriteFile (use the same expected URL pattern as for files[].url). Locate
TestRewriteRewritesRelativeURLs and after reading got (from os.ReadFile) add an
assertion that string(got) contains the expected rewritten path URL (constructed
the same way as want) so the rewriteFile(path, repo, tag) behavior for the
top-level path is verified.

Fixes the broken auto-updater (manifests on the CDN pointed to relative
paths but binaries lived only on GitHub Releases) and ships a redesigned,
coordinated update UX.

Publish pipeline:
- New hack/rewrite_manifest_urls helper rewrites manifest files[].url and
  path to absolute https://github.com/<repo>/releases/download/<tag>/<file>
  URLs so binaries stay on GitHub Releases (download stats preserved)
  while manifests are served from dl.devsy.sh.
- hack/merge_mac_metadata moved into its own subdir for layout consistency
  and now dedupes files[] by URL with last-write-wins (fixes stale entries
  that caused electron-updater to advertise mismatched sha512 hashes).
- Dropped the fetch-existing-from-live-site step that was the source of
  the stale entries.
- New hack/smoke_test_manifests helper HEAD-checks every URL in every
  deployed manifest before Netlify deploy (with GITHUB_TOKEN auth for
  github.com hosts via url.Parse + Hostname comparison).
- release.yml updated to invoke all three Go helpers in the correct order.

Main process (desktop/src/main/updater.ts):
- Extended UpdateStatus with progress, code (dev-mode/unsupported/network/
  feed-error/verification), and an idle state.
- Wired download-progress events to the renderer.
- Replaced the misleading "not code-signed" message with real error codes.
- Removed the native dialog.showMessageBox on update-downloaded; the
  renderer now owns the post-download UX.
- Honors the user's auto-download setting end-to-end via new
  set_auto_download / get_auto_download IPC.
- Settings file write is atomic (write-to-temp + rename) and error-wrapped.
- Exposes getLastStatus for the tray.

IPC (desktop/src/main/ipc.ts):
- New handlers: download_update, get_app_version, get_auto_download,
  set_auto_download. set_release_channel narrows the channel via an
  explicit ReleaseChannel local for clarity.

Renderer:
- New global Svelte 5 store at lib/stores/updates.svelte.ts subscribed
  once at App root.
- New UpdateDialog.svelte renders all six states (checking, available,
  downloading with progress bar, downloaded with restart CTA, not-available,
  error). Release notes sanitized through DOMPurify in a $derived value.
- New update-toasts.ts fires svelte-sonner toasts for available, downloaded,
  error, and not-available, gated on user-initiated checks so background
  errors stay silent. Dedupes by (state, version).
- New UpdateBadge.svelte passive header indicator visible only when an
  update is in flight or ready.
- New UpdatesSection.svelte extracted from SettingsPage so the Updates +
  Version + Release Channel controls are self-contained; SettingsPage
  embeds a single <UpdatesSection /> tag.
- New tray "Install Update v<version>" item via the pure
  buildUpdateMenuItems helper.
- localStorage autoUpdate is now a fast cache; main-process JSON is the
  canonical source. syncAutoUpdateFromMain seeds the renderer store at
  boot and respects a userTouchedAutoUpdate flag so a fast user toggle
  during boot is not clobbered.

Tests:
- desktop/src/main/__tests__/updater.test.ts (dev-mode emission, progress
  propagation, autoDownload honor, no native dialog on update-downloaded).
- desktop/src/main/__tests__/tray.test.ts (buildUpdateMenuItems across
  all states, click wiring, missing-version fallback).
- desktop/src/renderer/.../update/UpdateDialog.test.ts (per-state rendering).
- desktop/src/renderer/.../update/update-toasts.test.ts (dedupe, user-initiated
  gating, dev-mode silence, auto-download-on/off branches,
  available->downloading->available transition).
- hack/rewrite_manifest_urls/main_test.go (relative rewrite, absolute skip,
  non-map entries, filename filter).
- hack/merge_mac_metadata/main_test.go (URL dedupe last-write-wins, empty
  files, missing-url entries, single-source no-op).
- hack/smoke_test_manifests/main_test.go (200 success, 404 fail, relative
  URL fail, missing dir tolerated).

163 desktop tests pass, 13 Go tests pass across 4 packages, svelte-check
clean, electron-builder build succeeds.
@skevetter skevetter force-pushed the improve-update-ux branch from 5887adc to c1f52e5 Compare May 29, 2026 22:05
@skevetter skevetter merged commit 75bb02f into main May 29, 2026
59 checks passed
@skevetter skevetter deleted the improve-update-ux branch May 29, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant