From d2d64ef45d55dcac96677324ee26dfa2e4b77972 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 8 Aug 2026 22:06:53 +0100 Subject: [PATCH] Refactor Windows installer for side-by-side updates and improved directory handling - Updated the NSIS installer to support side-by-side updates, allowing the running application to remain active while a new version is staged in a separate directory. - Modified the installation directory handling to ensure the correct paths are used for shortcuts and registry entries. - Enhanced the update process to include a retry mechanism for folder swaps, improving reliability during updates. - Adjusted comments and documentation for clarity on the update process and its implications. --- packaging/windows/farmcontrol.nsi | 10 +-- packaging/windows/installer.nsh | 100 ++++++++++++++++-------------- src/desktop/updater-runner.js | 3 +- src/desktop/winappupdate.js | 4 +- 4 files changed, 61 insertions(+), 56 deletions(-) diff --git a/packaging/windows/farmcontrol.nsi b/packaging/windows/farmcontrol.nsi index 100882a..37586c2 100644 --- a/packaging/windows/farmcontrol.nsi +++ b/packaging/windows/farmcontrol.nsi @@ -60,12 +60,14 @@ Function .onInit !insertmacro progressStatus "Starting Farm Control installation..." !insertmacro progressPercent 5 - ; In-app updates keep the running process alive for progress UI. + ; In-app updates install into Farm Control.new while the running process + ; keeps using Farm Control; the folders are swapped after exit (/RESTARTFC). ; Interactive / silent fresh installs still remove the previous version first. ${If} $IsInAppUpdate == "1" - !insertmacro prepareInPlaceUpdate + !insertmacro prepareSideBySideUpdate !insertmacro progressPercent 15 ${Else} + StrCpy $FinalInstDir $INSTDIR !insertmacro uninstallPreviousFarmControl !insertmacro progressPercent 20 ${EndIf} @@ -97,7 +99,7 @@ Section "Farm Control" SecMain !insertmacro progressStatus "Writing uninstall information..." !insertmacro progressPercent 90 - WriteRegStr HKCU "Software\Tom Butcher\Farm Control" "InstallDir" $INSTDIR + WriteRegStr HKCU "Software\Tom Butcher\Farm Control" "InstallDir" $FinalInstDir WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ "DisplayName" "Farm Control" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ @@ -105,7 +107,7 @@ Section "Farm Control" SecMain WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ "Publisher" "Tom Butcher" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ - "UninstallString" "$INSTDIR\Uninstall.exe" + "UninstallString" "$FinalInstDir\Uninstall.exe" WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ "NoModify" 1 WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index e40a9d8..3dd4900 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -5,11 +5,13 @@ Var ProgressLogFile Var IsInAppUpdate Var RestartAfterInstall +Var FinalInstDir !macro initProgressLog StrCpy $ProgressLogFile "" StrCpy $IsInAppUpdate "0" StrCpy $RestartAfterInstall "0" + StrCpy $FinalInstDir "" ${GetParameters} $R9 @@ -104,36 +106,48 @@ Var RestartAfterInstall ExecWait 'taskkill /F /IM launcher.exe /T' $R0 !macroend -; Windows allows renaming open executables; move locked binaries aside so File can replace them. -!macro moveAsideIfPresent filePath - ${If} ${FileExists} "${filePath}" - Delete "${filePath}.old" - ClearErrors - Rename "${filePath}" "${filePath}.old" - ClearErrors +; Side-by-side update: install into Farm Control.new while the running app +; keeps using Farm Control, then swap folders after the process exits. +!macro prepareSideBySideUpdate + !insertmacro progressPhase "Preparing update" + !insertmacro progressStatus "Preparing staging folder for update..." + + StrCpy $FinalInstDir $INSTDIR + StrCpy $INSTDIR "$FinalInstDir.new" + + ${If} ${FileExists} "$INSTDIR" + DetailPrint "Removing leftover staging folder..." + RMDir /r "$INSTDIR" ${EndIf} !macroend -; Prefer renaming the whole bin dir (works even with open files inside). -!macro prepareInPlaceUpdate - !insertmacro progressPhase "Preparing update" - !insertmacro progressStatus "Preparing files for update..." +!macro swapUpdateStaging + !insertmacro progressPhase "Applying update" + !insertmacro progressStatus "Replacing Farm Control with the new version..." + DetailPrint "Replacing Farm Control with staged update..." - ${If} ${FileExists} "$INSTDIR\bin.old" - RMDir /r "$INSTDIR\bin.old" - ${EndIf} - - ${If} ${FileExists} "$INSTDIR\bin" - ClearErrors - Rename "$INSTDIR\bin" "$INSTDIR\bin.old" - ${If} ${Errors} - !insertmacro moveAsideIfPresent "$INSTDIR\bin\FarmControl.exe" - !insertmacro moveAsideIfPresent "$INSTDIR\bin\launcher.exe" - !insertmacro moveAsideIfPresent "$INSTDIR\bin\bun.exe" - !insertmacro moveAsideIfPresent "$INSTDIR\bin\bspatch.exe" - !insertmacro moveAsideIfPresent "$INSTDIR\bin\zig-zstd.exe" + StrCpy $R7 0 + swap_delete_retry: + RMDir /r "$FinalInstDir" + ${If} ${FileExists} "$FinalInstDir" + IntOp $R7 $R7 + 1 + ${If} $R7 < 20 + Sleep 500 + Goto swap_delete_retry + ${EndIf} + !insertmacro progressFailure "Could not remove the previous Farm Control installation." + Abort ${EndIf} + + ClearErrors + Rename "$INSTDIR" "$FinalInstDir" + ${If} ${Errors} + !insertmacro progressFailure "Could not activate the new Farm Control installation." + Abort ${EndIf} + + StrCpy $INSTDIR $FinalInstDir + !insertmacro progressLog "installer:STATUS:Update files activated" !macroend !macro uninstallPreviousFarmControl @@ -180,15 +194,15 @@ Var RestartAfterInstall !macro createDesktopShortcut SetShellVarContext current - SetOutPath "$INSTDIR\bin" - CreateShortCut "$DESKTOP\Farm Control.lnk" "$INSTDIR\bin\FarmControl.exe" "" "$INSTDIR\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control" + SetOutPath "$FinalInstDir\bin" + CreateShortCut "$DESKTOP\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control" !macroend !macro createStartMenuShortcut SetShellVarContext current CreateDirectory "$SMPROGRAMS\Farm Control" - SetOutPath "$INSTDIR\bin" - CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$INSTDIR\bin\FarmControl.exe" "" "$INSTDIR\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control" + SetOutPath "$FinalInstDir\bin" + CreateShortCut "$SMPROGRAMS\Farm Control\Farm Control.lnk" "$FinalInstDir\bin\FarmControl.exe" "" "$FinalInstDir\bin\FarmControl.exe" 0 SW_SHOWNORMAL "" "Farm Control" !macroend !macro removeDesktopShortcut @@ -202,16 +216,6 @@ Var RestartAfterInstall RMDir "$SMPROGRAMS\Farm Control" !macroend -!macro cleanupUpdateBackups - ; Best-effort; may fail while the previous process still holds handles. - RMDir /r "$INSTDIR\bin.old" - Delete "$INSTDIR\bin\FarmControl.exe.old" - Delete "$INSTDIR\bin\launcher.exe.old" - Delete "$INSTDIR\bin\bun.exe.old" - Delete "$INSTDIR\bin\bspatch.exe.old" - Delete "$INSTDIR\bin\zig-zstd.exe.old" -!macroend - !macro customInstall !insertmacro progressPhase "Configuring Farm Control" !insertmacro progressStatus "Registering farmcontrol URI handler..." @@ -219,20 +223,15 @@ Var RestartAfterInstall DeleteRegKey HKCU "Software\Classes\farmcontrol" WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol" WriteRegStr HKCU "Software\Classes\farmcontrol" "URL Protocol" "" - WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$INSTDIR\bin\FarmControl.exe" + WriteRegStr HKCU "Software\Classes\farmcontrol\DefaultIcon" "" "$FinalInstDir\bin\FarmControl.exe" WriteRegStr HKCU "Software\Classes\farmcontrol\shell" "" "" WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" "" - WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\bun.exe" "$INSTDIR\bin\deeplink.js" "%1"' + WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$FinalInstDir\bin\bun.exe" "$FinalInstDir\bin\deeplink.js" "%1"' !insertmacro progressStatus "Creating shortcuts..." DetailPrint "Creating shortcuts" !insertmacro createDesktopShortcut !insertmacro createStartMenuShortcut - - ; Don't remove bin.old during an in-app update — the running process still uses it. - ${If} $IsInAppUpdate != "1" - !insertmacro cleanupUpdateBackups - ${EndIf} !macroend !macro customUnInstall @@ -241,7 +240,8 @@ Var RestartAfterInstall DeleteRegKey HKCU "Software\Classes\farmcontrol" !macroend -; Wait for the running app to exit, then relaunch. Only used when /RESTARTFC is passed. +; Wait for the running app to exit, swap staged update into place, then relaunch. +; Only used when /RESTARTFC is passed. Function restartFarmControlAfterUpdate ${If} $RestartAfterInstall != "1" Return @@ -262,7 +262,11 @@ Function restartFarmControlAfterUpdate Goto restart_wait_loop ${EndIf} + ${If} $IsInAppUpdate == "1" + !insertmacro swapUpdateStaging + ${EndIf} + !insertmacro progressStatus "Starting Farm Control..." - SetOutPath "$INSTDIR\bin" - Exec "$INSTDIR\bin\FarmControl.exe" + SetOutPath "$FinalInstDir\bin" + Exec "$FinalInstDir\bin\FarmControl.exe" FunctionEnd diff --git a/src/desktop/updater-runner.js b/src/desktop/updater-runner.js index ad6cc23..2cbf94a 100644 --- a/src/desktop/updater-runner.js +++ b/src/desktop/updater-runner.js @@ -81,8 +81,7 @@ export const resolveAppLaunchPath = () => { } if (process.platform === 'win32') { - // Prefer the installed launcher so post-update restart targets the new binary - // (in-app updates may leave the running process under bin.old). + // Prefer the installed launcher so restarts target the registered install dir. const installDir = readWindowsInstallDirFromRegistry() if (installDir) { const launcherFromRegistry = resolveExistingLauncher( diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index 2fc0687..6a21d3d 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -220,8 +220,8 @@ export const launchWindowsInstaller = async ( // Silent NSIS install in a detached child process (not a batch file). // /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall) - // /UPDATE = in-app update (skip killing this process; overwrite in place) - // /RESTARTFC = installer waits for this process to exit and relaunches the app + // /UPDATE = in-app update (stage into Farm Control.new; swap after exit) + // /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches // /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS) const installerArgs = [ '/S',