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 ) ) }) }) }