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." !insertmacro progressFailure "Installation failed."
FunctionEnd FunctionEnd
Function .onInstSuccess
Call restartFarmControlAfterUpdate
FunctionEnd
Section "Farm Control" SecMain Section "Farm Control" SecMain
SectionIn RO SectionIn RO

View File

@ -4,10 +4,12 @@
Var ProgressLogFile Var ProgressLogFile
Var IsInAppUpdate Var IsInAppUpdate
Var RestartAfterInstall
!macro initProgressLog !macro initProgressLog
StrCpy $ProgressLogFile "" StrCpy $ProgressLogFile ""
StrCpy $IsInAppUpdate "0" StrCpy $IsInAppUpdate "0"
StrCpy $RestartAfterInstall "0"
${GetParameters} $R9 ${GetParameters} $R9
@ -17,6 +19,12 @@ Var IsInAppUpdate
StrCpy $IsInAppUpdate "1" StrCpy $IsInAppUpdate "1"
${EndIf} ${EndIf}
ClearErrors
${GetOptions} $R9 "/RESTARTFC" $R8
${IfNot} ${Errors}
StrCpy $RestartAfterInstall "1"
${EndIf}
; Prefer env var so paths with spaces are reliable; /LOG= remains supported. ; Prefer env var so paths with spaces are reliable; /LOG= remains supported.
ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG" ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG"
${If} $ProgressLogFile == "" ${If} $ProgressLogFile == ""
@ -229,3 +237,24 @@ Var IsInAppUpdate
!insertmacro removeStartMenuShortcut !insertmacro removeStartMenuShortcut
DeleteRegKey HKCU "Software\Classes\farmcontrol" DeleteRegKey HKCU "Software\Classes\farmcontrol"
!macroend !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}.`); throw new Error(`App updates are not supported on ${process.platform}.`);
} }
scheduleAppRestart(); if (process.platform === "darwin") {
// Give the detached restart watcher a moment to start, and let the UI show 100%. scheduleAppRestart();
}
// Give the UI a moment to show completion before the app exits.
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
Utils.quit(); Utils.quit();
}; };

View File

@ -1,5 +1,5 @@
import { execSync, spawn } from 'node:child_process' 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 os from 'node:os'
import path from 'node:path' import path from 'node:path'
import process from 'node:process' import process from 'node:process'
@ -131,57 +131,6 @@ const spawnDetachedMacRestart = ({ appLaunchPath, parentPid }) => {
child.unref() 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 } = {}) => { export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => {
const appLaunchPath = resolveAppLaunchPath() const appLaunchPath = resolveAppLaunchPath()
@ -190,10 +139,5 @@ export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => {
return { appLaunchPath } return { appLaunchPath }
} }
if (process.platform === 'win32') { throw new Error(`App restart scheduling is not supported on ${process.platform}.`)
spawnDetachedWindowsRestart({ appLaunchPath, parentPid })
return { appLaunchPath }
}
throw new Error(`App updates are not supported on ${process.platform}.`)
} }

View File

@ -84,11 +84,16 @@ export const prepareInstallerPath = async (installerPath) => {
return resolvedPath return resolvedPath
} }
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { const startWindowsInstallerProgressWatch = (
logPath,
sendProgress,
onInstallSuccessful
) => {
let installerOutput = '' let installerOutput = ''
let offset = 0 let offset = 0
let lastPercent = null let lastPercent = null
let lastMessage = null let lastMessage = null
let installSuccessNotified = false
const poll = async () => { const poll = async () => {
try { try {
@ -114,6 +119,15 @@ const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
message: resolvedMessage message: resolvedMessage
}) })
} }
if (
!installSuccessNotified &&
isWindowsInstallSuccessful(installerOutput) &&
!isWindowsInstallFailed(installerOutput)
) {
installSuccessNotified = true
onInstallSuccessful?.(installerOutput)
}
} finally { } finally {
await handle.close() await handle.close()
} }
@ -162,81 +176,12 @@ export const launchWindowsInstaller = async (
mainWindow.show?.() mainWindow.show?.()
} }
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Silent NSIS install in a child process (not a batch file). let settled = false
// /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}`]
const installerProcess = spawn(resolvedPath, installerArgs, { const settleSuccess = (output) => {
detached: false, if (settled) return
env: { settled = true
...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 { percent, message } = parseWindowsInstallerProgress(output) const { percent, message } = parseWindowsInstallerProgress(output)
@ -247,6 +192,85 @@ export const launchWindowsInstaller = async (
}) })
resolve() 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)
}) })
}) })
} }