- 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.
350 lines
10 KiB
JavaScript
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
|
|
)
|
|
)
|
|
})
|
|
})
|
|
}
|