farmcontrol-ui/src/desktop/winappupdate.js
Tom Butcher 84a6a86bf3 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.
2026-08-09 12:08:53 +01:00

350 lines
10 KiB
JavaScript

import { spawn } from 'child_process'
import { promises as fs } from 'fs'
import os from 'os'
import path from 'path'
const PE_MZ_HEADER = Buffer.from([0x4d, 0x5a]) // "MZ"
const COPY_PROGRESS_PERCENT = 75
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const formatBytes = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`
}
const parseWindowsInstallerProgress = (output) => {
const lines = String(output || '').split(/\r?\n/)
let percent = null
let message = 'Installing update...'
let copyTotalBytes = 0
let copiedBytes = 0
for (const line of lines) {
if (line.startsWith('installer:PHASE:')) {
message = line.slice('installer:PHASE:'.length).trim() || message
} else if (line.startsWith('installer:STATUS:')) {
const status = line.slice('installer:STATUS:'.length).trim()
if (status) message = status
} else if (line.startsWith('installer:%')) {
const value = Number.parseFloat(line.slice('installer:%'.length))
if (Number.isFinite(value)) {
percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
}
} else if (line.startsWith('installer:COPY_TOTAL:')) {
const value = Number.parseInt(
line.slice('installer:COPY_TOTAL:'.length),
10
)
if (Number.isFinite(value) && value > 0) {
copyTotalBytes = value
copiedBytes = 0
}
} else if (line.startsWith('installer:COPY_FILE:')) {
const copyFile = line.match(/^installer:COPY_FILE:(\d+):(.*)$/)
if (copyFile) {
const fileBytes = Number.parseInt(copyFile[1], 10)
const relativePath = copyFile[2].trim()
if (Number.isFinite(fileBytes)) {
copiedBytes += fileBytes
if (copyTotalBytes > 0) {
percent = Math.min(
COPY_PROGRESS_PERCENT,
Math.round((copiedBytes / copyTotalBytes) * COPY_PROGRESS_PERCENT)
)
}
}
if (relativePath) {
message = `Copying ${relativePath} (${formatBytes(fileBytes)})`
}
}
} else if (
line.startsWith('installer: ') &&
!line.startsWith('installer:PHASE:') &&
!line.startsWith('installer:STATUS:') &&
!line.startsWith('installer:%') &&
!line.startsWith('installer:COPY_')
) {
const text = line.slice('installer: '.length).trim()
if (text) message = text
}
}
return { percent, message }
}
const isWindowsInstallSuccessful = (output) =>
/installer: The install was successful\./i.test(output)
const isWindowsInstallFailed = (output) =>
/installer: The install failed/i.test(output)
const isValidNsisInstaller = async (filePath) => {
const handle = await fs.open(filePath, 'r')
try {
const header = Buffer.alloc(PE_MZ_HEADER.length)
await handle.read(header, 0, header.length, 0)
return header.equals(PE_MZ_HEADER)
} finally {
await handle.close()
}
}
export const prepareInstallerPath = async (installerPath) => {
const fileName = path.basename(installerPath)
const updateDir = path.join(
os.homedir(),
'AppData',
'Local',
'FarmControl',
'Updates'
)
await fs.mkdir(updateDir, { recursive: true })
const stablePath = path.join(updateDir, fileName)
await fs.copyFile(installerPath, stablePath)
const resolvedPath = await fs.realpath(stablePath)
const stats = await fs.stat(resolvedPath)
if (!stats.isFile() || stats.size === 0) {
throw new Error('Update installer file is missing or empty.')
}
if (!(await isValidNsisInstaller(resolvedPath))) {
throw new Error(
'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.'
)
}
return resolvedPath
}
const startWindowsInstallerProgressWatch = (
logPath,
sendProgress,
{ onInstallSuccessful, onInstallFailed, onStalled, stallTimeoutMs }
) => {
let installerOutput = ''
let offset = 0
let lastPercent = null
let lastMessage = null
let outcomeNotified = false
let lastActivityAt = Date.now()
const poll = async () => {
try {
const stat = await fs.stat(logPath)
if (stat.size <= offset) return
const handle = await fs.open(logPath, 'r')
try {
const buffer = Buffer.alloc(stat.size - offset)
await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size
installerOutput += buffer.toString('utf8')
lastActivityAt = Date.now()
const { percent, message } =
parseWindowsInstallerProgress(installerOutput)
const resolvedMessage = message || 'Installing update...'
if (percent !== lastPercent || resolvedMessage !== lastMessage) {
lastPercent = percent
lastMessage = resolvedMessage
sendProgress({
phase: 'installing',
percent,
message: resolvedMessage
})
}
if (!outcomeNotified) {
if (isWindowsInstallFailed(installerOutput)) {
outcomeNotified = true
onInstallFailed?.(installerOutput)
} else if (isWindowsInstallSuccessful(installerOutput)) {
outcomeNotified = true
onInstallSuccessful?.(installerOutput)
}
}
} finally {
await handle.close()
}
} catch (error) {
if (error?.code !== 'ENOENT') {
console.error('[app-update] installer log poll error:', error)
}
}
}
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)
})
}, 200)
return async () => {
clearInterval(intervalId)
await poll()
return installerOutput
}
}
export const launchWindowsInstaller = async (
mainWindow,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
) => {
const resolvedPath = await prepareInstallerPath(installerPath)
const logPath = path.join(path.dirname(resolvedPath), 'install.log')
sendProgress({
phase: 'installing',
percent: 0,
message: 'Installing update...'
})
await fs.unlink(logPath).catch(() => {})
// Allow file handles from the download/copy to settle before the installer opens.
await sleep(2000)
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.focus?.()
mainWindow.show?.()
}
return new Promise((resolve, reject) => {
let settled = false
const settleSuccess = (output) => {
if (settled) return
settled = true
const { percent, message } = parseWindowsInstallerProgress(output)
sendProgress({
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
resolve()
}
const settleFailure = (message) => {
if (settled) return
settled = true
sendProgress({
phase: 'error',
percent: null,
message
})
reject(new Error(message))
}
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress,
{
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
)
)
}
}
)
const refreshInstallerOutput = async () => {
return await stopProgressWatch()
}
// 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= = progress log (installer:% / PHASE / STATUS / COPY_TOTAL / COPY_FILE)
const installerCommandLine = [
`"${resolvedPath}"`,
'/S',
'/UPDATE',
'/RESTARTFC',
`/PARENTPID=${process.pid}`,
`/LOG="${logPath}"`
].join(' ')
// 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, "''")}'`
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
}
)
spawnerProcess.on('error', async (error) => {
const output = await refreshInstallerOutput()
console.error('[app-update] installer launch error:', error)
settleFailure(getInstallErrorMessage(error, output))
})
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()
settleFailure(
getInstallErrorMessage(
new Error(`Failed to launch the update installer (code ${code}).`),
output
)
)
})
})
}