@@ -61,6 +61,14 @@ import {
6161 UPDATE_STATUS_CHANNEL ,
6262 UPDATE_STATUS_GET_CHANNEL ,
6363} from "../shared/update" ;
64+ import {
65+ planCompletedUpdateCheck ,
66+ planDownloadedUpdate ,
67+ planFatalAutoInstallOnQuit ,
68+ planUpdateCheck ,
69+ statusAfterUpdateError ,
70+ type UpdateCheckTrigger ,
71+ } from "./updater-state" ;
6472
6573// Pin userData to a friendly app-name-scoped dir BEFORE app.ready so every
6674// Electron-side consumer (electron-store, electron-log, window-state) lands
@@ -741,7 +749,9 @@ const registerIpcHandlers = () => {
741749 // Crash-screen escape hatch: a recurring sidecar crash may already be
742750 // fixed upstream. Reuses the menu flow — staged updates prompt to install,
743751 // "no updates" / failures surface in their own dialogs.
744- ipcMain . handle ( "executor:updates:check" , ( ) => runUpdateCheck ( { alertOnFail : true } ) ) ;
752+ ipcMain . handle ( "executor:updates:check" , ( ) =>
753+ runUpdateCheck ( { alertOnFail : true , trigger : "manual" } ) ,
754+ ) ;
745755 ipcMain . handle ( UPDATE_STATUS_GET_CHANNEL , ( ) : DesktopUpdateStatus => updateStatus ) ;
746756 ipcMain . handle ( UPDATE_INSTALL_CHANNEL , async ( ) : Promise < void > => {
747757 const version = "version" in updateStatus ? updateStatus . version : "" ;
@@ -754,6 +764,7 @@ const registerIpcHandlers = () => {
754764 }
755765 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle, matching
756766 // the native dialog's restart path.
767+ stopSupervisedMonitor ( ) ;
757768 if ( connection ) {
758769 await stopConnection ( connection ) ;
759770 connection = null ;
@@ -804,6 +815,7 @@ const registerIpcHandlers = () => {
804815// ──────────────────────────────────────────────────────────────────────
805816
806817let downloadedUpdateVersion : string | null = null ;
818+ let declinedUpdateVersion : string | null = null ;
807819let updateDialogOpen = false ;
808820
809821// Renderer-facing auto-update status. The web shell renders a desktop-native
@@ -845,11 +857,16 @@ const applyFakeUpdateFromEnv = () => {
845857 state === "available" ||
846858 state === "downloaded" ||
847859 state === "downloading" ||
860+ state === "error" ||
848861 state === "installing"
849862 ) {
850863 if ( state === "downloaded" ) downloadedUpdateVersion = version ;
851864 setUpdateStatus (
852- state === "downloading" ? { state, version, percent : 100 } : { state, version } ,
865+ state === "downloading"
866+ ? { state, version, percent : 100 }
867+ : state === "error"
868+ ? { state, version, message : "Update failed" }
869+ : { state, version } ,
853870 ) ;
854871 }
855872 } catch ( error ) {
@@ -874,12 +891,15 @@ const promptInstallUpdate = async (version: string) => {
874891 if ( response . response === 0 ) {
875892 // Stop the sidecar cleanly before Squirrel.Mac swaps the bundle. A
876893 // supervised daemon is left running — it's independent of this bundle.
894+ stopSupervisedMonitor ( ) ;
877895 if ( connection ) {
878896 await stopConnection ( connection ) ;
879897 connection = null ;
880898 }
881899 autoUpdater . quitAndInstall ( false , true ) ;
900+ return ;
882901 }
902+ declinedUpdateVersion = version ;
883903 } finally {
884904 updateDialogOpen = false ;
885905 }
@@ -910,25 +930,41 @@ const setupAutoUpdater = () => {
910930 }
911931 } ) ;
912932 autoUpdater . on ( "update-downloaded" , ( info : UpdateInfo ) => {
913- downloadedUpdateVersion = info . version ;
914- setUpdateStatus ( { state : "downloaded" , version : info . version } ) ;
915- void promptInstallUpdate ( info . version ) ;
933+ const decision = planDownloadedUpdate ( {
934+ stagedVersion : downloadedUpdateVersion ,
935+ declinedVersion : declinedUpdateVersion ,
936+ incomingVersion : info . version ,
937+ trigger : "boot" ,
938+ } ) ;
939+ downloadedUpdateVersion = decision . stagedVersion ;
940+ declinedUpdateVersion = decision . declinedVersion ;
941+ setUpdateStatus ( decision . status ) ;
942+ if ( decision . promptVersion ) void promptInstallUpdate ( decision . promptVersion ) ;
916943 } ) ;
917944 autoUpdater . on ( "error" , ( err : Error ) => {
918945 log . warn ( "[updater] error" , err ) ;
946+ setUpdateStatus (
947+ statusAfterUpdateError ( updateStatus , "Update failed. Check again from the menu." ) ,
948+ ) ;
949+ pendingUpdateVersion = null ;
919950 } ) ;
920951
921952 setInterval ( ( ) => {
922- if ( downloadedUpdateVersion ) return ; // already staged; waiting on the user
923- void runUpdateCheck ( { alertOnFail : false } ) ;
953+ void runUpdateCheck ( { alertOnFail : false , trigger : "interval" } ) ;
924954 } , UPDATE_POLL_INTERVAL_MS ) ;
925955} ;
926956
927957interface UpdateCheckOptions {
928958 readonly alertOnFail : boolean ;
959+ readonly trigger : UpdateCheckTrigger ;
929960}
930961
931- const runUpdateCheck = async ( { alertOnFail } : UpdateCheckOptions ) => {
962+ type UpdateCheckResult = {
963+ readonly isUpdateAvailable ?: boolean ;
964+ readonly updateInfo ?: { readonly version ?: string } ;
965+ } ;
966+
967+ const runUpdateCheck = async ( { alertOnFail, trigger } : UpdateCheckOptions ) => {
932968 if ( ! app . isPackaged ) {
933969 if ( alertOnFail ) {
934970 await dialog . showMessageBox ( {
@@ -939,14 +975,23 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => {
939975 }
940976 return ;
941977 }
942- if ( downloadedUpdateVersion ) {
943- await promptInstallUpdate ( downloadedUpdateVersion ) ;
944- return ;
945- }
978+ const checkPlan = planUpdateCheck ( { stagedVersion : downloadedUpdateVersion , trigger } ) ;
979+ if ( ! checkPlan . check ) return ;
946980 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: surface network/update failures only when the user asked
947981 try {
948- const result = await autoUpdater . checkForUpdates ( ) ;
982+ const result = ( await autoUpdater . checkForUpdates ( ) ) as UpdateCheckResult | null ;
949983 const newer = result ?. isUpdateAvailable === true ;
984+ const promptAfterCheck = planCompletedUpdateCheck ( {
985+ stagedVersion : checkPlan . promptVersionAfterCheck ,
986+ trigger,
987+ updateAvailable : newer ,
988+ availableVersion :
989+ typeof result ?. updateInfo ?. version === "string" ? result . updateInfo . version : null ,
990+ } ) . promptVersion ;
991+ if ( promptAfterCheck ) {
992+ await promptInstallUpdate ( promptAfterCheck ) ;
993+ return ;
994+ }
950995 if ( ! alertOnFail ) return ;
951996 if ( newer ) return ; // 'update-downloaded' handler will fire the install dialog
952997 await dialog . showMessageBox ( {
@@ -956,6 +1001,9 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => {
9561001 } ) ;
9571002 } catch ( error ) {
9581003 log . warn ( "[updater] check failed" , error ) ;
1004+ setUpdateStatus (
1005+ statusAfterUpdateError ( updateStatus , "Update failed. Check again from the menu." ) ,
1006+ ) ;
9591007 if ( ! alertOnFail ) return ;
9601008 await dialog . showMessageBox ( {
9611009 type : "error" ,
@@ -978,8 +1026,12 @@ const handleFatalSidecarFailure = async (error: unknown) => {
9781026 // Install whatever finishes downloading by the time the user quits the
9791027 // failure dialog; if it downloads while the dialog is open, the regular
9801028 // 'update-downloaded' prompt offers an immediate restart instead.
981- autoUpdater . autoInstallOnAppQuit = true ;
982- void runUpdateCheck ( { alertOnFail : false } ) ;
1029+ const autoInstallPlan = planFatalAutoInstallOnQuit ( {
1030+ packaged : true ,
1031+ retryAfterReset : false ,
1032+ } ) ;
1033+ if ( autoInstallPlan . enableDuringFailure ) autoUpdater . autoInstallOnAppQuit = true ;
1034+ void runUpdateCheck ( { alertOnFail : false , trigger : "boot" } ) ;
9831035 }
9841036 // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: sidecar startup failures arrive as plain Node errors and render in a native dialog
9851037 const detail = error instanceof Error ? ( error . stack ?? error . message ) : String ( error ) ;
@@ -995,7 +1047,13 @@ const handleFatalSidecarFailure = async (error: unknown) => {
9951047 // Damaged executor state (failed migration, corrupt SQLite) makes startup
9961048 // fail forever — updating can't fix it. Offer the move-aside reset and one
9971049 // immediate retry. Returns true when boot should be attempted again.
998- if ( response === 1 && ( await confirmResetState ( ) ) ) {
1050+ const retryAfterReset = response === 1 && ( await confirmResetState ( ) ) ;
1051+ const autoInstallPlan = planFatalAutoInstallOnQuit ( {
1052+ packaged : app . isPackaged ,
1053+ retryAfterReset,
1054+ } ) ;
1055+ if ( retryAfterReset ) {
1056+ if ( autoInstallPlan . restoreAfterRecovery ) autoUpdater . autoInstallOnAppQuit = false ;
9991057 const { backupDir } = resetExecutorState ( ) ;
10001058 await announceBackup ( backupDir ) ;
10011059 return true ;
@@ -1011,7 +1069,7 @@ const installApplicationMenu = () => {
10111069 { role : "about" } ,
10121070 {
10131071 label : "Check for Updates…" ,
1014- click : ( ) => void runUpdateCheck ( { alertOnFail : true } ) ,
1072+ click : ( ) => void runUpdateCheck ( { alertOnFail : true , trigger : "manual" } ) ,
10151073 } ,
10161074 {
10171075 label : "Export Diagnostics…" ,
@@ -1105,7 +1163,7 @@ const boot = async () => {
11051163 // A crashing sidecar may be a broken release — quietly stage any
11061164 // available update so the install prompt appears on its own (same
11071165 // self-heal as the fatal startup path).
1108- void runUpdateCheck ( { alertOnFail : false } ) ;
1166+ void runUpdateCheck ( { alertOnFail : false , trigger : "boot" } ) ;
11091167 } ) ;
11101168 // Prefer an OS-supervised daemon: attach to one that's running, kick one
11111169 // that's installed, or offer to install on first run. Quitting the app then
@@ -1121,7 +1179,7 @@ const boot = async () => {
11211179 try {
11221180 await createWindow ( supervised ) ;
11231181 armSupervisedMonitor ( ) ;
1124- void runUpdateCheck ( { alertOnFail : false } ) ;
1182+ void runUpdateCheck ( { alertOnFail : false , trigger : "boot" } ) ;
11251183 return ;
11261184 } catch ( error ) {
11271185 log . warn ( "Failed to load supervised daemon; falling back to managed sidecar" , error ) ;
@@ -1196,7 +1254,7 @@ const boot = async () => {
11961254 // Check at boot. If an update is available, autoDownload pulls it and
11971255 // the 'update-downloaded' handler fires the install dialog. Silent on
11981256 // no-update / failure so we don't bother users on every launch.
1199- void runUpdateCheck ( { alertOnFail : false } ) ;
1257+ void runUpdateCheck ( { alertOnFail : false , trigger : "boot" } ) ;
12001258} ;
12011259
12021260if ( ensureSingleInstance ( ) ) {
0 commit comments