Skip to content

Commit 6eeee7c

Browse files
feat(desktop): background auto-download updates (#1020)
* feat(desktop): background auto-download updates on Mac and Windows - Webview preload: expose nexuUpdater bridge so web app receives update events (fixes #1006, #1008) - Windows: rewrite update driver for in-app download with progress events, rate limiting (1MB/s background), SHA256 verification, and exe-launch install - UpdateManager: add background/foreground download mode — silently download after periodic check, only show UI when complete or when user manually checks - Mac: enable electron-updater autoDownload for silent background download - Fix quitAndInstall gate to allow external-installer apply mode - Fix desktop shell and web app install guards for external-installer * fix(desktop): fix checking state stuck when user-initiated check meets auto-download The onAvailable and onError handlers suppressed all events when autoDownload was enabled, but this also blocked user-initiated checks from ever exiting the "checking" phase. Add a userInitiatedCheck flag so manual checks always deliver events to the renderer while periodic checks still suppress UI and download silently. Also refine the background download surfacing: when user clicks "Check for updates" during a background download, show the "available" state (version + Install button) instead of immediately showing download progress, letting the user decide whether to proceed. * fix(desktop): enable periodic checks for local-test-feed and reduce Windows initial delay - shouldStartDesktopPeriodicUpdateChecks now returns true for local-test-feed experience, so local validation builds with a configured feed URL also run periodic update checks - Reduce Windows initial delay from 45s to 30s * fix(desktop): hide duplicate UpdateBanner in immersive mode and add dismiss to ready state - Hide UpdateBanner in immersive mode (packaged app) since the web app's UpdateFloatCard handles user-facing update UI - Add close button and "Later" option to the UpdateFloatCard ready state so users can dismiss the notification * feat(desktop): add update:get-status IPC so settings page reflects background download state When the settings page mounts after a background download has already completed, it now queries the current update status via the new update:get-status IPC channel and immediately shows "Install" instead of "Check now". * chore(desktop): improve local auto-update validation * fix(desktop): tighten background update flows --------- Co-authored-by: mashu <chizblank@gmail.com>
1 parent 5fecd34 commit 6eeee7c

17 files changed

Lines changed: 1077 additions & 126 deletions

apps/desktop/main/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1755,7 +1755,8 @@ app.whenReady().then(async () => {
17551755
const updateMgr = new UpdateManager(win, orchestrator, {
17561756
channel: runtimeConfig.updates.channel,
17571757
feedUrl: runtimeConfig.urls.updateFeed,
1758-
initialDelayMs: process.platform === "win32" ? 45_000 : 0,
1758+
autoDownload: true,
1759+
initialDelayMs: process.platform === "win32" ? 30_000 : 0,
17591760
prepareForUpdateInstall: runtimeLifecycle.prepareForUpdateInstall
17601761
? async (args: PrepareForUpdateInstallArgs) => {
17611762
await runtimeLifecycle.prepareForUpdateInstall?.(args);

apps/desktop/main/ipc.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ export function registerIpcHandlers(
744744
if (!updateManager) {
745745
return { updateAvailable: false };
746746
}
747-
return updateManager.checkNow();
747+
return updateManager.checkNow({ userInitiated: true });
748748
}
749749

750750
case "update:get-capability": {
@@ -781,6 +781,13 @@ export function registerIpcHandlers(
781781
return { version: app.getVersion() };
782782
}
783783

784+
case "update:get-status": {
785+
if (!updateManager) {
786+
return { phase: "idle", version: null };
787+
}
788+
return updateManager.getStatus();
789+
}
790+
784791
case "update:set-channel": {
785792
const typedPayload =
786793
payload as HostInvokePayloadMap["update:set-channel"];

apps/desktop/main/updater/update-manager.ts

Lines changed: 184 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { UnsupportedUpdateDriver } from "./unsupported-update-driver";
2222
import type { PlatformUpdateDriver } from "./update-driver";
2323
import { WindowsUpdateDriver } from "./windows-update-driver";
2424

25+
type DownloadMode = "idle" | "background" | "foreground";
26+
2527
export interface UpdateManagerOptions {
2628
source?: UpdateSource;
2729
channel?: UpdateChannelName;
@@ -104,6 +106,14 @@ export class UpdateManager {
104106
private currentFeedUrl: string;
105107
private readonly platform: NodeJS.Platform;
106108
private readonly driver: PlatformUpdateDriver;
109+
private readonly autoDownload: boolean;
110+
private downloadMode: DownloadMode = "idle";
111+
private pendingVersion: string | null = null;
112+
private pendingReleaseDate: string | undefined = undefined;
113+
private pendingReleaseNotes: string | undefined = undefined;
114+
private pendingActionUrl: string | undefined = undefined;
115+
private downloadComplete = false;
116+
private userInitiatedCheck = false;
107117
private checkInProgress: Promise<{ updateAvailable: boolean }> | null = null;
108118
private lastProgressLogAt = 0;
109119
private lastProgressLogPercent: number | null = null;
@@ -125,6 +135,7 @@ export class UpdateManager {
125135
this.initialDelayMs = options?.initialDelayMs ?? 0;
126136
this.launchdCtx = options?.launchd;
127137
this.options = options;
138+
this.autoDownload = options?.autoDownload ?? false;
128139
this.platform = options?.platform ?? process.platform;
129140
this.driver = createUpdateDriver(
130141
this.platform,
@@ -159,6 +170,22 @@ export class UpdateManager {
159170
return this.driver.capability;
160171
}
161172

173+
getStatus(): {
174+
phase: "idle" | "downloading" | "ready";
175+
version: string | null;
176+
} {
177+
if (this.downloadComplete) {
178+
return { phase: "ready", version: this.pendingVersion };
179+
}
180+
if (
181+
this.downloadMode === "background" ||
182+
this.downloadMode === "foreground"
183+
) {
184+
return { phase: "downloading", version: this.pendingVersion };
185+
}
186+
return { phase: "idle", version: null };
187+
}
188+
162189
private getDiagnostic(partial?: {
163190
remoteVersion?: string;
164191
remoteReleaseDate?: string;
@@ -197,6 +224,42 @@ export class UpdateManager {
197224
remoteReleaseDate: info.releaseDate,
198225
});
199226
this.logCheck("update event: update available", diagnostic);
227+
228+
// Always store pending info for later surfacing
229+
this.pendingVersion = info.version;
230+
this.pendingReleaseDate = info.releaseDate;
231+
this.pendingReleaseNotes = info.releaseNotes;
232+
this.pendingActionUrl = info.actionUrl;
233+
234+
if (this.autoDownload && !this.userInitiatedCheck) {
235+
// Periodic check: suppress UI, start downloading silently
236+
this.downloadMode = "background";
237+
this.logCheck(
238+
"auto-download: starting background download",
239+
diagnostic,
240+
);
241+
242+
// On Windows, trigger download manually (Mac electron-updater
243+
// auto-downloads when autoDownload is true)
244+
if (this.platform === "win32") {
245+
void this.driver.downloadUpdate();
246+
}
247+
return;
248+
}
249+
250+
// User-initiated check: always send the event so the renderer
251+
// can exit "checking" state. Background download still proceeds.
252+
if (this.autoDownload) {
253+
this.downloadMode = "background";
254+
this.logCheck(
255+
"auto-download: user-initiated check, sending available event and starting background download",
256+
diagnostic,
257+
);
258+
if (this.platform === "win32") {
259+
void this.driver.downloadUpdate();
260+
}
261+
}
262+
200263
this.send("update:available", {
201264
version: info.version,
202265
releaseNotes: info.releaseNotes,
@@ -228,6 +291,12 @@ export class UpdateManager {
228291
this.getDiagnostic(),
229292
);
230293
}
294+
295+
// Suppress progress events during background download
296+
if (this.downloadMode === "background") {
297+
return;
298+
}
299+
231300
this.send("update:progress", {
232301
percent: progress.percent,
233302
bytesPerSecond: progress.bytesPerSecond,
@@ -243,11 +312,29 @@ export class UpdateManager {
243312
remoteReleaseDate: info.releaseDate,
244313
}),
245314
);
315+
this.downloadMode = "idle";
316+
this.downloadComplete = true;
317+
// Always notify renderer when download completes
246318
this.send("update:downloaded", { version: info.version });
247319
},
248320
onError: (error) => {
249321
const diagnostic = this.getDiagnostic();
250322
this.logCheck(`update error: ${error.message}`, diagnostic);
323+
324+
if (this.downloadMode === "background" && !this.userInitiatedCheck) {
325+
// Suppress error UI during background-only download
326+
this.logCheck(
327+
"auto-download: background download failed, suppressing UI error",
328+
diagnostic,
329+
);
330+
this.downloadMode = "idle";
331+
return;
332+
}
333+
334+
if (this.downloadMode === "background") {
335+
this.downloadMode = "idle";
336+
}
337+
251338
this.send("update:error", { message: error.message, diagnostic });
252339
},
253340
});
@@ -267,9 +354,51 @@ export class UpdateManager {
267354
}
268355
}
269356

270-
async checkNow(): Promise<{ updateAvailable: boolean }> {
357+
async checkNow(options?: {
358+
userInitiated?: boolean;
359+
}): Promise<{ updateAvailable: boolean }> {
271360
const startedAt = Date.now();
361+
this.userInitiatedCheck = options?.userInitiated ?? false;
272362
this.logCheck("update check start", this.getDiagnostic());
363+
364+
// If background download already completed, surface it immediately
365+
if (this.downloadComplete && this.pendingVersion) {
366+
this.logCheck(
367+
"update check: background download already complete, surfacing",
368+
this.getDiagnostic(),
369+
);
370+
// Brief "checking" flash so the settings button shows a transition
371+
this.send("update:checking", this.getDiagnostic());
372+
this.send("update:downloaded", { version: this.pendingVersion });
373+
return { updateAvailable: true };
374+
}
375+
376+
// If background download is in progress, show "available" with version
377+
// info but keep downloading in the background. User can decide whether
378+
// to install — clicking Install will switch to foreground mode with
379+
// visible progress.
380+
if (this.downloadMode === "background" && this.pendingVersion) {
381+
this.logCheck(
382+
"update check: background download in progress, surfacing available state",
383+
this.getDiagnostic(),
384+
);
385+
386+
// Brief "checking" flash so the settings button shows a transition
387+
this.send("update:checking", this.getDiagnostic());
388+
389+
const diagnostic = this.getDiagnostic({
390+
remoteVersion: this.pendingVersion,
391+
remoteReleaseDate: this.pendingReleaseDate,
392+
});
393+
this.send("update:available", {
394+
version: this.pendingVersion,
395+
releaseNotes: this.pendingReleaseNotes,
396+
actionUrl: this.pendingActionUrl,
397+
diagnostic,
398+
});
399+
return { updateAvailable: true };
400+
}
401+
273402
if (this.checkInProgress) {
274403
this.logCheck(
275404
"update check skipped: already in progress",
@@ -306,6 +435,7 @@ export class UpdateManager {
306435
return { updateAvailable: false };
307436
} finally {
308437
this.checkInProgress = null;
438+
this.userInitiatedCheck = false;
309439
}
310440
})();
311441

@@ -324,6 +454,15 @@ export class UpdateManager {
324454
return { ok: false };
325455
}
326456

457+
// Switch to foreground mode and remove rate limit
458+
this.downloadMode = "foreground";
459+
if (
460+
this.platform === "win32" &&
461+
this.driver instanceof WindowsUpdateDriver
462+
) {
463+
this.driver.setRateLimit(null);
464+
}
465+
327466
return this.driver.downloadUpdate();
328467
}
329468

@@ -404,63 +543,61 @@ export class UpdateManager {
404543

405544
logStep("phase 1 cleanup end");
406545

407-
// --- Phase 2: Process verification ---
408-
// Two sweeps of SIGKILL to clear all Nexu sidecar processes. Uses both
409-
// authoritative sources (launchd labels, runtime-ports.json) and pgrep.
410-
const firstSweepStartedAt = Date.now();
411-
let { clean, remainingPids } = await ensureNexuProcessesDead({
412-
timeoutMs: 8_000,
413-
intervalMs: 200,
414-
});
415-
this.logCheck(
416-
`quit-and-install: first sweep complete in ${Date.now() - firstSweepStartedAt}ms (${clean ? "clean" : `survivors: ${remainingPids.join(", ")}`})`,
417-
this.getDiagnostic(),
418-
);
419-
420-
if (!clean) {
421-
const secondSweepStartedAt = Date.now();
422-
({ clean, remainingPids } = await ensureNexuProcessesDead({
423-
timeoutMs: 5_000,
546+
// --- Phase 2 & 3: macOS-only process verification and lock check ---
547+
// On Windows, the NSIS installer handles process detection itself.
548+
if (this.platform === "darwin") {
549+
// Two sweeps of SIGKILL to clear all Nexu sidecar processes. Uses both
550+
// authoritative sources (launchd labels, runtime-ports.json) and pgrep.
551+
const firstSweepStartedAt = Date.now();
552+
let { clean, remainingPids } = await ensureNexuProcessesDead({
553+
timeoutMs: 8_000,
424554
intervalMs: 200,
425-
}));
555+
});
426556
this.logCheck(
427-
`quit-and-install: second sweep complete in ${Date.now() - secondSweepStartedAt}ms (${clean ? "clean" : `survivors: ${remainingPids.join(", ")}`})`,
557+
`quit-and-install: first sweep complete in ${Date.now() - firstSweepStartedAt}ms (${clean ? "clean" : `survivors: ${remainingPids.join(", ")}`})`,
428558
this.getDiagnostic(),
429559
);
430-
}
431560

432-
// --- Phase 3: Evidence-based install decision ---
433-
// Even with surviving processes, the update may be safe if those
434-
// processes don't hold file handles to critical update paths. Use
435-
// lsof to check whether the .app bundle or extracted sidecar dirs
436-
// are actually locked.
437-
const lockCheckStartedAt = Date.now();
438-
const { locked, lockedPaths } = await checkCriticalPathsLocked();
439-
this.logCheck(
440-
`quit-and-install: critical-path lock check complete in ${Date.now() - lockCheckStartedAt}ms (${locked ? `locked: ${lockedPaths.join(", ")}` : "unlocked"})`,
441-
this.getDiagnostic(),
442-
);
561+
if (!clean) {
562+
const secondSweepStartedAt = Date.now();
563+
({ clean, remainingPids } = await ensureNexuProcessesDead({
564+
timeoutMs: 5_000,
565+
intervalMs: 200,
566+
}));
567+
this.logCheck(
568+
`quit-and-install: second sweep complete in ${Date.now() - secondSweepStartedAt}ms (${clean ? "clean" : `survivors: ${remainingPids.join(", ")}`})`,
569+
this.getDiagnostic(),
570+
);
571+
}
443572

444-
if (locked) {
445-
// Critical paths are held open — installing now would fail or
446-
// corrupt the app. Skip this attempt; electron-updater will
447-
// re-detect the pending update on next launch.
573+
// Evidence-based install decision: check if critical paths are locked.
574+
const lockCheckStartedAt = Date.now();
575+
const { locked, lockedPaths } = await checkCriticalPathsLocked();
448576
this.logCheck(
449-
`quit-and-install: ABORTING — critical paths still locked: ${lockedPaths.join(", ")}`,
577+
`quit-and-install: critical-path lock check complete in ${Date.now() - lockCheckStartedAt}ms (${locked ? `locked: ${lockedPaths.join(", ")}` : "unlocked"})`,
450578
this.getDiagnostic(),
451579
);
452-
return;
453-
}
454580

455-
if (!clean) {
456-
// Processes alive but no critical file handles — safe to proceed.
457-
this.logCheck(
458-
"quit-and-install: residual processes exist but no critical path locks, proceeding",
459-
this.getDiagnostic(),
460-
);
581+
if (locked) {
582+
this.logCheck(
583+
`quit-and-install: ABORTING — critical paths still locked: ${lockedPaths.join(", ")}`,
584+
this.getDiagnostic(),
585+
);
586+
return;
587+
}
588+
589+
if (!clean) {
590+
this.logCheck(
591+
"quit-and-install: residual processes exist but no critical path locks, proceeding",
592+
this.getDiagnostic(),
593+
);
594+
}
461595
}
462596

463-
if (this.driver.capability.applyMode !== "in-app") {
597+
if (
598+
this.driver.capability.applyMode !== "in-app" &&
599+
this.driver.capability.applyMode !== "external-installer"
600+
) {
464601
this.logCheck(
465602
"quit-and-install skipped: capability disabled on this platform",
466603
this.getDiagnostic(),
@@ -470,7 +607,7 @@ export class UpdateManager {
470607

471608
// Set force-quit flag so window close handlers don't intercept the exit
472609
(app as unknown as Record<string, unknown>).__nexuForceQuit = true;
473-
logStep("triggering autoUpdater.quitAndInstall");
610+
logStep("triggering update apply");
474611
await this.driver.applyUpdate();
475612
}
476613

0 commit comments

Comments
 (0)