Enhance Windows installer progress monitoring with improved outcome handling

- Refactored the `startWindowsInstallerProgressWatch` function to notify on installation success, failure, and stalling more reliably.
- Introduced a timeout mechanism to detect stalled installers, improving user feedback during the update process.
- Updated the `launchWindowsInstaller` function to pass new callback parameters for handling different installation outcomes, enhancing overall robustness.
This commit is contained in:
Tom Butcher 2026-08-09 12:08:53 +01:00
parent bb2b8148c7
commit 84a6a86bf3

View File

@ -126,13 +126,14 @@ export const prepareInstallerPath = async (installerPath) => {
const startWindowsInstallerProgressWatch = (
logPath,
sendProgress,
onInstallSuccessful
{ onInstallSuccessful, onInstallFailed, onStalled, stallTimeoutMs }
) => {
let installerOutput = ''
let offset = 0
let lastPercent = null
let lastMessage = null
let installSuccessNotified = false
let outcomeNotified = false
let lastActivityAt = Date.now()
const poll = async () => {
try {
@ -145,6 +146,7 @@ const startWindowsInstallerProgressWatch = (
await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size
installerOutput += buffer.toString('utf8')
lastActivityAt = Date.now()
const { percent, message } =
parseWindowsInstallerProgress(installerOutput)
@ -160,13 +162,14 @@ const startWindowsInstallerProgressWatch = (
})
}
if (
!installSuccessNotified &&
isWindowsInstallSuccessful(installerOutput) &&
!isWindowsInstallFailed(installerOutput)
) {
installSuccessNotified = true
onInstallSuccessful?.(installerOutput)
if (!outcomeNotified) {
if (isWindowsInstallFailed(installerOutput)) {
outcomeNotified = true
onInstallFailed?.(installerOutput)
} else if (isWindowsInstallSuccessful(installerOutput)) {
outcomeNotified = true
onInstallSuccessful?.(installerOutput)
}
}
} finally {
await handle.close()
@ -179,6 +182,18 @@ const startWindowsInstallerProgressWatch = (
}
const intervalId = setInterval(() => {
// The installer runs detached (outside our job object), so a dead
// installer only shows up as the log going quiet before any outcome.
if (
!outcomeNotified &&
stallTimeoutMs > 0 &&
Date.now() - lastActivityAt > stallTimeoutMs
) {
outcomeNotified = true
onStalled?.(installerOutput)
return
}
poll().catch((error) => {
console.error('[app-update] installer log poll error:', error)
})
@ -249,8 +264,22 @@ export const launchWindowsInstaller = async (
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress,
(output) => {
settleSuccess(output)
{
stallTimeoutMs: 3 * 60 * 1000,
onInstallSuccessful: (output) => {
settleSuccess(output)
},
onInstallFailed: (output) => {
settleFailure(getInstallErrorMessage(null, output))
},
onStalled: (output) => {
settleFailure(
getInstallErrorMessage(
new Error('The update installer stopped responding.'),
output
)
)
}
}
)
@ -258,65 +287,63 @@ export const launchWindowsInstaller = async (
return await stopProgressWatch()
}
// Silent NSIS install in a detached child process (not a batch file).
// Silent NSIS install.
// /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall)
// /UPDATE = in-app update (stage into Farm Control.new; swap after exit)
// /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches
// /PARENTPID = exact process to wait for (CEF may outlive the launcher)
// /LOG= + FARMCONTROL_INSTALL_LOG = progress log
// (installer:% / PHASE / STATUS / COPY_TOTAL / COPY_FILE)
const installerArgs = [
// /LOG= = progress log (installer:% / PHASE / STATUS / COPY_TOTAL / COPY_FILE)
const installerCommandLine = [
`"${resolvedPath}"`,
'/S',
'/UPDATE',
'/RESTARTFC',
`/PARENTPID=${process.pid}`,
`/LOG=${logPath}`
]
`/LOG="${logPath}"`
].join(' ')
const installerProcess = spawn(resolvedPath, installerArgs, {
detached: true,
env: {
...process.env,
FARMCONTROL_INSTALL_LOG: logPath
},
stdio: 'ignore',
windowsHide: true
})
// This process runs inside a Windows job object (CEF/launcher) that kills
// every child process when the app exits — a directly spawned installer
// (even with detached: true) inherits the job and dies mid folder-swap the
// moment the app quits. Creating the process via WMI (Win32_Process.Create)
// parents it to the WMI provider host, outside our job, so it survives.
const quoteForPowershell = (value) => `'${value.replace(/'/g, "''")}'`
installerProcess.unref()
const spawnerProcess = spawn(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${quoteForPowershell(installerCommandLine)} }; exit $result.ReturnValue`
],
{
stdio: 'ignore',
windowsHide: true
}
)
installerProcess.on('error', async (error) => {
spawnerProcess.on('error', async (error) => {
const output = await refreshInstallerOutput()
console.error('[app-update] installer error:', error)
console.error('[app-update] installer launch error:', error)
settleFailure(getInstallErrorMessage(error, output))
})
installerProcess.on('exit', async (code) => {
spawnerProcess.on('exit', async (code) => {
// PowerShell exits as soon as the installer is created; from here the
// log watcher owns success/failure. Only a non-zero code (WMI create
// failed) means the installer never started.
if (code === 0 || settled) return
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
)
settleFailure(
getInstallErrorMessage(
new Error(`Failed to launch the update installer (code ${code}).`),
output
)
return
}
if (
isWindowsInstallFailed(output) ||
!isWindowsInstallSuccessful(output)
) {
settleFailure(getInstallErrorMessage(null, output))
return
}
settleSuccess(output)
)
})
})
}