Add support for automatic restart after successful installation on Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Implemented a new function to restart Farm Control after a successful installation, triggered by the `/RESTARTFC` option.
- Enhanced the installer script to wait for the application to close before relaunching, improving user experience during updates.
- Updated the app update process to conditionally schedule a restart based on the platform, ensuring compatibility with macOS.
This commit is contained in:
Tom Butcher 2026-08-04 00:45:52 +01:00
parent 6f2f7ed5c2
commit ee62045416
5 changed files with 138 additions and 134 deletions

View File

@ -71,6 +71,10 @@ Function .onInstFailed
!insertmacro progressFailure "Installation failed."
FunctionEnd
Function .onInstSuccess
Call restartFarmControlAfterUpdate
FunctionEnd
Section "Farm Control" SecMain
SectionIn RO

View File

@ -4,10 +4,12 @@
Var ProgressLogFile
Var IsInAppUpdate
Var RestartAfterInstall
!macro initProgressLog
StrCpy $ProgressLogFile ""
StrCpy $IsInAppUpdate "0"
StrCpy $RestartAfterInstall "0"
${GetParameters} $R9
@ -17,6 +19,12 @@ Var IsInAppUpdate
StrCpy $IsInAppUpdate "1"
${EndIf}
ClearErrors
${GetOptions} $R9 "/RESTARTFC" $R8
${IfNot} ${Errors}
StrCpy $RestartAfterInstall "1"
${EndIf}
; Prefer env var so paths with spaces are reliable; /LOG= remains supported.
ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG"
${If} $ProgressLogFile == ""
@ -229,3 +237,24 @@ Var IsInAppUpdate
!insertmacro removeStartMenuShortcut
DeleteRegKey HKCU "Software\Classes\farmcontrol"
!macroend
; Wait for the running app to exit, then relaunch. Only used when /RESTARTFC is passed.
Function restartFarmControlAfterUpdate
${If} $RestartAfterInstall != "1"
Return
${EndIf}
!insertmacro progressStatus "Waiting for Farm Control to close..."
Sleep 1000
restart_wait_loop:
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0
${If} $R0 == 0
Sleep 1000
Goto restart_wait_loop
${EndIf}
!insertmacro progressStatus "Starting Farm Control..."
SetOutPath "$INSTDIR\bin"
Exec "$INSTDIR\bin\launcher.exe"
FunctionEnd

View File

@ -206,8 +206,11 @@ const launchInstallerAndRestart = async (
throw new Error(`App updates are not supported on ${process.platform}.`);
}
if (process.platform === "darwin") {
scheduleAppRestart();
// Give the detached restart watcher a moment to start, and let the UI show 100%.
}
// Give the UI a moment to show completion before the app exits.
await new Promise((resolve) => setTimeout(resolve, 1000));
Utils.quit();
};

View File

@ -1,5 +1,5 @@
import { execSync, spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
@ -131,57 +131,6 @@ const spawnDetachedMacRestart = ({ appLaunchPath, parentPid }) => {
child.unref()
}
const spawnDetachedWindowsRestart = ({ appLaunchPath, parentPid }) => {
const updateDir = path.join(
os.homedir(),
'AppData',
'Local',
'FarmControl',
'Updates'
)
const scriptPath = path.join(updateDir, `restart-${parentPid}.cmd`)
const appLaunchWin = appLaunchPath.replaceAll('/', '\\')
const appDirWin = path.dirname(appLaunchWin)
// Separate process: wait until Farm Control's PID exits, then relaunch launcher.exe.
const script = `@echo off
setlocal EnableExtensions
set "PARENT_PID=${parentPid}"
set "APP_LAUNCHER=${appLaunchWin}"
set "APP_DIR=${appDirWin}"
:waitparent
tasklist /FI "PID eq %PARENT_PID%" 2>NUL | findstr /I /C:"%PARENT_PID%" >NUL
if not errorlevel 1 (
timeout /t 1 /nobreak >NUL
goto waitparent
)
timeout /t 1 /nobreak >NUL
cd /d "%APP_DIR%"
start "" "%APP_LAUNCHER%"
timeout /t 1 /nobreak >NUL
del "%~f0" >NUL 2>&1
`
mkdirSync(updateDir, { recursive: true })
writeFileSync(scriptPath, script, 'utf8')
const child = spawn(
process.env.ComSpec || 'cmd.exe',
['/d', '/c', scriptPath],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
cwd: updateDir
}
)
child.unref()
}
export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => {
const appLaunchPath = resolveAppLaunchPath()
@ -190,10 +139,5 @@ export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => {
return { appLaunchPath }
}
if (process.platform === 'win32') {
spawnDetachedWindowsRestart({ appLaunchPath, parentPid })
return { appLaunchPath }
}
throw new Error(`App updates are not supported on ${process.platform}.`)
throw new Error(`App restart scheduling is not supported on ${process.platform}.`)
}

View File

@ -84,11 +84,16 @@ export const prepareInstallerPath = async (installerPath) => {
return resolvedPath
}
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
const startWindowsInstallerProgressWatch = (
logPath,
sendProgress,
onInstallSuccessful
) => {
let installerOutput = ''
let offset = 0
let lastPercent = null
let lastMessage = null
let installSuccessNotified = false
const poll = async () => {
try {
@ -114,6 +119,15 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
message: resolvedMessage
})
}
if (
!installSuccessNotified &&
isWindowsInstallSuccessful(installerOutput) &&
!isWindowsInstallFailed(installerOutput)
) {
installSuccessNotified = true
onInstallSuccessful?.(installerOutput)
}
} finally {
await handle.close()
}
@ -162,81 +176,12 @@ export const launchWindowsInstaller = async (
mainWindow.show?.()
}
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => {
// Silent NSIS install in a 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)
// /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS)
const installerArgs = ['/S', '/UPDATE', `/LOG=${logPath}`]
let settled = false
const installerProcess = spawn(resolvedPath, installerArgs, {
detached: false,
env: {
...process.env,
FARMCONTROL_INSTALL_LOG: logPath
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
let processOutput = ''
installerProcess.stdout?.on('data', (data) => {
processOutput += data.toString('utf8')
})
installerProcess.stderr?.on('data', (data) => {
processOutput += data.toString('utf8')
})
installerProcess.on('error', async (error) => {
const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
sendProgress({
phase: 'error',
percent: null,
message
})
reject(new Error(message))
})
installerProcess.on('exit', async (code) => {
const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput
await fs.unlink(logPath).catch(() => {})
if (code !== 0) {
const message = getInstallErrorMessage(
new Error(`Installer exited with code ${code}.`),
output
)
sendProgress({
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) {
const message = getInstallErrorMessage(null, output)
sendProgress({
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const settleSuccess = (output) => {
if (settled) return
settled = true
const { percent, message } = parseWindowsInstallerProgress(output)
@ -247,6 +192,85 @@ export const launchWindowsInstaller = async (
})
resolve()
}
const settleFailure = (message) => {
if (settled) return
settled = true
sendProgress({
phase: 'error',
percent: null,
message
})
reject(new Error(message))
}
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress,
(output) => {
settleSuccess(output)
}
)
const refreshInstallerOutput = async () => {
return await stopProgressWatch()
}
// 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
// /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS)
const installerArgs = [
'/S',
'/UPDATE',
'/RESTARTFC',
`/LOG=${logPath}`
]
const installerProcess = spawn(resolvedPath, installerArgs, {
detached: true,
env: {
...process.env,
FARMCONTROL_INSTALL_LOG: logPath
},
stdio: 'ignore',
windowsHide: true
})
installerProcess.unref()
installerProcess.on('error', async (error) => {
const output = await refreshInstallerOutput()
console.error('[app-update] installer error:', error)
settleFailure(getInstallErrorMessage(error, output))
})
installerProcess.on('exit', async (code) => {
const output = await refreshInstallerOutput()
await fs.unlink(logPath).catch(() => {})
if (settled) return
if (code !== 0) {
settleFailure(
getInstallErrorMessage(
new Error(`Installer exited with code ${code}.`),
output
)
)
return
}
if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) {
settleFailure(getInstallErrorMessage(null, output))
return
}
settleSuccess(output)
})
})
}