@@ -22,6 +22,8 @@ import { UnsupportedUpdateDriver } from "./unsupported-update-driver";
2222import type { PlatformUpdateDriver } from "./update-driver" ;
2323import { WindowsUpdateDriver } from "./windows-update-driver" ;
2424
25+ type DownloadMode = "idle" | "background" | "foreground" ;
26+
2527export 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