Skip to content

Let the desktop app download its own updates and install them when you quit, when automatic updates are on - #1074

Merged
sak0a merged 2 commits into
mainfrom
port/desktop-install-on-quit
Oct 31, 2026
Merged

sak0a merged 2 commits into
mainfrom
port/desktop-install-on-quit

Conversation

@sak0a

@sak0a sak0a commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Built on #1067 and #1072 (both merged).

What Changed

  • When a node's owner turns on Update automatically (from Update provider CLIs on their own while nobody uses Ryco, when a node's owner turns it on #1067, with update checks on), the desktop app that hosts the node now updates itself too.
  • The backend tells the app with a new app-update-policy frame on their private pipe: once at start, then on every change. It sends "off" on a dev server and when it cannot read its settings.
  • With the policy on, the startup check (15 s) and the poll check (every 4 h) also download the update they find. The check and its download share one reservation, so a second scheduled check joins the running one.
  • Windows (NSIS) and a Linux AppImage named without a version: the app arms electron-updater's autoInstallOnAppQuit before the download starts. A normal quit then installs the update silently. The app does not start again on its own.
  • macOS, and a Linux AppImage named with its version (as the release files are): nothing installs at quit. The update downloads in the background and waits for the person to choose Restart. electron-updater would move such an AppImage update to a new versioned file name that nothing starts, so the person's launcher would point at a deleted file.
  • A quit during a system shutdown, a Windows logoff or after SIGTERM installs nothing; the update waits for a later normal quit.
  • A version whose download or install failed is not downloaded or armed again in the background. The person can still install it by hand.
  • Turning the policy off, a new backend, a failed install and a relaunch all disarm the install at quit. Only the version whose download completed while armed is armed again.
  • Every install (at Restart or at quit) writes desktop-update-install.json in the state directory. On the next launch:
    • on the new version, the app shows "Updated to X" once (the record accepts only version-shaped values);
    • still on the old version, the install did not apply, so that version is skipped in the background.
  • Nodes now advertise what their desktop app does (desktopAppUpdates: install-on-quit or restart; absent on CLI nodes and development builds; the app and its backend decide it with the same shared function). In Settings → Updates, the "Update automatically" row of a desktop app node says what happens on its platform.
  • docs/release.md describes the new behaviour.

Why

This is the last phase (U4) of opt-in unattended updates. Until now, every desktop update needed two clicks: download, then restart. This PR ports the mechanism of T3 Code's unmerged pingdotgg/t3code#17588: background download on startup and poll checks, install at quit except on macOS, no background retry of a failed version, and an install record written at quit. Unlike T3, it stays off unless the node's owner turns it on.

Security review

  • Nothing installs unless the owner's setting is on, together with update checks. The policy is automaticUpdates && enableProviderUpdateChecks, and never on a dev server. The setting is off by default and owner-only (Update provider CLIs on their own while nobody uses Ryco, when a node's owner turns it on #1067), and Agent Control can neither read nor change it. The app starts with the policy off. It turns off again when the backend exits, until the next backend reports, and the backend reports off when it cannot read its settings. With the policy off, behaviour is exactly as before: autoInstallOnAppQuit stays false and checks never download.
  • Nothing installs while the system shuts down or logs out. Ryco's own session-end handler quits the app (with exit code 0) when a running-chats dialog is open, and a Linux shutdown sends SIGTERM. The app's quit hook runs before electron-updater's quit installer; during a shutdown notice (30 s), a Windows session end or after SIGTERM it disarms autoInstallOnAppQuit and writes no install record.
  • macOS never installs without the person's Restart. desktopAppAutomaticUpdateMode returns restart on macOS, and the app then never sets autoInstallOnAppQuit. Only with that flag on does electron-updater's MacUpdater hand the update to Squirrel.Mac. The unsigned installer still runs only from the explicit install path.
  • The app never relaunches by itself. Install at quit is electron-updater's own handler: it calls install(isSilent = true, isForceRunAfter = false), and only on a quit with exit code 0. NSIS gets no --force-run. AppImage runs with APPIMAGE_EXIT_AFTER_INSTALL, so the AppRun script does not start the app. relaunchDesktopApp disarms before its app.exit(0), which still emits quit. Nothing calls quitAndInstall unattended.
  • Same channel, no downgrade, verification unchanged. Background downloads only follow a check that went through the existing update-available handler: the version must match DESKTOP_UPDATE_CHANNEL and the files must belong to the app's flavor, so Ryco and Ryco Nightly never cross. allowDowngrade = false and autoDownload = false are unchanged, and the download is the existing downloadAvailableUpdate with its watchdog. electron-updater's sha512 check and the Windows publisher signature check are untouched.
  • Only the download that completed while armed can install at quit. electron-updater's quit installer stays registered once added and installs whatever was downloaded last, so the app re-arms only when the downloaded version is the one that completed while armed. A version a person downloaded while the setting was off never installs at quit.
  • A failed version is never retried in the background. A failed download or install adds the version to a skip list for the session. The install record covers installs that failed across a restart. Arming and background downloads both check the list.
  • Only the app's own backend can set the policy. The frame arrives only on the inherited fd-4 pipe of the backend child that Desktop main spawned. Main accepts it only from the current backend generation (AutomaticAppUpdates.report(child, …) drops any other child). No preload or renderer IPC carries it, and a source-boundary test pins that. A remote client can only reach it by changing the owner-only setting on the node.
  • Nothing happens on dev builds. The backend reports off when devUrl is set, and it does not advertise desktopAppUpdates. The desktop updater itself is disabled in development and unpackaged builds (getAutoUpdateDisabledReason), so nothing is checked or downloaded there.
  • The new desktopAppUpdates capability appears in the pre-auth environment descriptor. It is a two-value literal, and the descriptor already shows platform.os. hostedBoundaries.test.ts lists it.

Validation

  • bun run check passes. bun run check:changed passes except the two known local-only failures (ProjectionPipeline.followUpQueue.test.ts, 026_CanonicalizeModelSelectionOptions.test.ts).
  • New tests:
    • automaticAppUpdates.test.ts (17): with the policy off, a check only checks; with it on, a check downloads and arms on Windows/AppImage; macOS downloads but never arms; a failed download is not repeated; turning the policy off disarms and turning it on re-arms; a download that finished while disarmed is never armed, nor a later one after an armed one; a quit during a shutdown or logout disarms and records nothing; a failed install is never armed again; a version from a not-applied install record is skipped; one reservation over the check and its download; a report from any backend but the current one is ignored; a new backend turns the policy off; the policy turns off when the backend exits but stays when the quit stopped it; a relaunch disarms.
    • updateInstallRecord.test.ts (5): the record turns into "updated", "not applied" or nothing, and only from semver values of at most 64 characters; it is settled and removed, kept while the install did not apply; a failed write returns false.
    • Shared protocol (2): the policy frame round trip and its rejects; the platform mode, including AppImage file names with and without a version.
    • Desktop pipe: the policy frame reaches its handler, and malformed ones are dropped. desktopControlBoundary.test.ts: the policy only comes from the child's pipe and never from preload; the quit hook passes the shutdown, session-end and SIGTERM state.
    • desktopAppUpdatePolicy.test.ts (4): the policy is sent at start and on changes; a change during the first read is not overwritten by the older read; unreadable settings and dev servers report off.
    • ServerEnvironment.test.ts: the capability for desktop mode, and its absence in web mode and dev builds.
    • Web: AutomaticUpdatesSettings.logic.test.ts (2), updatedNotice.logic.test.ts (2), and the UpdatesSettings.browser.tsx row text (browser test passes).
  • Tests fail without the change: I reverted each implementation file in turn, or mutated the controller, and each listed test failed. Specifically: no parser branch, no pipe routing, old main.ts, old ServerEnvironment.ts, old row component; macOS arming, retrying failed versions, no reservation, accepting any child, keeping the arm when the policy turns off, and never arming. New modules fail to import without the change.
  • desktopMainBundleSplitting.test.ts passes: electron-updater stays a lazy require.
  • Server boot: 7,002,907 B against 7,001,239 B on the same base (+1,668 B; cap 7,105,000). The boundary test now lists the small updates/desktopAppUpdatePolicy.ts reporter as boot. After the review fixes and the rebase onto Let CLI service nodes update Ryco itself while nobody uses them, when automatic updates are on #1072: 7,009,877 B (the fixes added 204 B, the AppImage name check in the shared module).
  • Web boot: 525.2 KiB of 526 KiB after rebasing on the current Update provider CLIs on their own while nobody uses Ryco, when a node's owner turns it on #1067. Before the rebase it measured 523.9 → 524.0 KiB on the same base. The +0.1 KiB is the capability field in the contracts schema. The new notice loads with the existing lazy desktop chunk. The review fixes only shorten one settings string in web and change comments in contracts, so web boot was not measured again.
  • Not verified: I did not launch any Electron build, and no real update was downloaded or installed. Install at quit on Windows NSIS and on an AppImage named without a version, the download-then-Restart flow on macOS and on a versioned AppImage, the "Updated to X" toast in a packaged app, and a Windows logoff or Linux shutdown with an armed update all still need a manual smoke test on real installs.

Review

An independent adversarial review found nothing critical or high. Fixed, each with a test that fails without it (checked by reverting or mutating the fix):

  • Medium: an install at quit could start during an OS shutdown or logout. Ryco's session-end handler quits the app with exit code 0, so electron-updater would start the silent installer as the session died (Windows logoff), or replace and run the AppImage during a Linux shutdown (SIGTERM). The quit hook now disarms and records nothing while a shutdown notice is active, after a Windows session end, or after SIGTERM. Tests: automaticAppUpdates.test.ts (quit during shutdown) and desktopControlBoundary.test.ts (the hook's wiring, SIGTERM sets its flag even while already quitting).
  • Low: a download made while the setting was off could be armed later. The app now remembers the version whose download completed while armed and re-arms only for that version. Test: a later download made while off is never armed.
  • Low: AppImage launchers broke after an unattended install. Release AppImages carry the version in their file name, so electron-updater moved the update to a new name that nothing started. An AppImage now installs at quit only when its file name has no version (electron-updater then overwrites it in place); otherwise it waits for Restart, like macOS. The capability, the settings text ("One installs only when you choose Restart.") and docs/release.md follow. Test: shared mode with versioned and unversioned AppImage names.
  • Low: the "Updated to X" notice could carry any text from the install record. The record now accepts only semver values of at most 64 characters. Test: updateInstallRecord.test.ts.
  • Hardening: a new backend turns the policy off until it reports. Test: a new backend before the old one's exit was seen.
  • Kept as is: the desktopAppUpdates capability is visible before sign-in. It says only desktop app vs CLI node and, on Linux, whether the app runs from an AppImage. The descriptor already shows the platform, and the coordinator decided to keep it.

🤖 Generated with Claude Code

@github-actions github-actions Bot added size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Oct 31, 2026
@sak0a
sak0a force-pushed the port/desktop-install-on-quit branch from 1166cd1 to 73f94c1 Compare October 31, 2026 14:20
sak0a and others added 2 commits October 31, 2026 15:48
…u quit, when automatic updates are on

With a node's "Update automatically" setting on (automaticUpdates, owner only, together with
update checks; never on a dev server), the desktop app that hosts the node now updates itself
too:

- The backend tells Desktop main the policy with a new `app-update-policy` frame on their private
  pipe, at start and on every change. Main takes it only from the backend generation it launched
  (no renderer IPC carries it), and it turns off when that backend exits until the next one
  reports, so it fails closed.
- The startup (15 s) and poll (4 h) checks then also download what they find, holding one
  reservation over the check and its download; a scheduled check that comes meanwhile joins it.
- Windows (NSIS) and Linux AppImage arm electron-updater's autoInstallOnAppQuit before the
  download, so a normal quit installs the update silently without starting the app again. macOS
  never arms it: Squirrel.Mac stages an armed update irreversibly and the unsigned installer
  replaces the bundle, so the download waits for Restart.
- A version whose download or install failed is not downloaded or armed again in the background.
  Turning the policy off, a failed install and a relaunch disarm the install at quit.
- Every install writes desktop-update-install.json at Restart or quit. The next launch shows
  "Updated to X" once, or, still on the old version, skips that version in the background.
- Nodes advertise what their desktop app does (`desktopAppUpdates`: install-on-quit or restart),
  and Settings, Updates says it on the "Update automatically" row of desktop app nodes.

Channel and flavor checks, allowDowngrade = false and electron-updater's signature and sha512
verification are unchanged. Port of T3 Code's unmerged background desktop updates
(pingdotgg/t3code#17588), behind the owner's setting instead of always on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e launchers working after an unattended update

- A quit during a system shutdown, a Windows logoff or after SIGTERM disarms the install at quit;
  the update installs at a later normal quit.
- Only the version whose download completed while armed is armed again: electron-updater's quit
  installer installs whatever was downloaded last.
- A Linux AppImage named with its version (as the releases are) now waits for Restart, like
  macOS: electron-updater would move the update to a new file name that nothing starts. The
  node's capability and the settings text follow.
- The install record accepts only version-shaped values (semver, at most 64 characters), so the
  "Updated to X" notice shows nothing else.
- A new backend turns the policy off until it reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sak0a
sak0a force-pushed the port/desktop-install-on-quit branch from 73f94c1 to c1c7821 Compare October 31, 2026 14:48
@sak0a
sak0a merged commit e2a787d into main Oct 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant