diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index a5b3462..295ffff 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -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) + ) }) }) }