import { spawn } from 'child_process' import { promises as fs } from 'fs' import os from 'os' import path from 'path' const MZ_HEADER = Buffer.from([0x4d, 0x5a]) const DEBUG_PREFIX = '[app-update][win-progress]' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const debugLog = () => {} const isValidWindowsExecutable = async (filePath) => { const handle = await fs.open(filePath, 'r') try { const header = Buffer.alloc(MZ_HEADER.length) await handle.read(header, 0, header.length, 0) return header.equals(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 isValidWindowsExecutable(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) => { let installerOutput = '' let lastLogSize = 0 let lastPercent = null let lastMessage = null let pollCount = 0 const poll = async () => { pollCount += 1 try { const stat = await fs.stat(logPath) if (stat.size === 0) { debugLog(`poll #${pollCount}: log exists but is empty`, { logPath }) return } if (stat.size === lastLogSize) { debugLog(`poll #${pollCount}: no new log data`, { logPath, size: stat.size }) return } const buffer = Buffer.alloc(stat.size) const handle = await fs.open(logPath, 'r') try { await handle.read(buffer, 0, stat.size, 0) } finally { await handle.close() } lastLogSize = stat.size installerOutput = decodeMsiLogBuffer(buffer) const { percent, message, stats } = parseWindowsInstallerProgress(installerOutput) const resolvedPercent = percent ?? lastPercent ?? 0 const resolvedMessage = message || 'Installing update...' debugLog(`poll #${pollCount}: parsed installer log`, { logPath, size: stat.size, textLength: installerOutput.length, preview: installerOutput.slice(0, 240).replace(/\s+/g, ' '), parsed: stats, resolvedPercent, resolvedMessage }) if (resolvedPercent !== lastPercent || resolvedMessage !== lastMessage) { debugLog(`poll #${pollCount}: sending progress update`, { percent: resolvedPercent, message: resolvedMessage }) lastPercent = resolvedPercent lastMessage = resolvedMessage sendProgress({ phase: 'installing', percent: resolvedPercent, message: resolvedMessage }) } else { debugLog(`poll #${pollCount}: progress unchanged, skipping UI update`, { percent: resolvedPercent, message: resolvedMessage }) } } catch (error) { if (error?.code === 'ENOENT') { debugLog(`poll #${pollCount}: log file not created yet`, { logPath }) return } console.error(`${DEBUG_PREFIX} installer log poll error:`, error) } } const intervalId = setInterval(() => { poll().catch((error) => { console.error(`${DEBUG_PREFIX} installer log poll error:`, error) }) }, 300) return async () => { clearInterval(intervalId) await poll() debugLog('stopped progress watch', { logPath, finalSize: lastLogSize, textLength: installerOutput.length, pollCount }) 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') debugLog('prepared installer', { installerPath, resolvedPath, logPath }) sendProgress({ phase: 'installing', percent: 0, message: 'Installing update...' }) await fs.unlink(logPath).catch(() => {}) await sleep(2000) const stopProgressWatch = startWindowsInstallerProgressWatch( logPath, sendProgress ) return new Promise((resolve, reject) => { let processOutput = '' const startedAt = Date.now() const installerArgs = [ '/i', resolvedPath, '/qn', '/norestart', 'ALLUSERS=2', 'MSIINSTALLPERUSER=1', 'REBOOT=ReallySuppress', '/L*v!', logPath ] debugLog('spawning NSIS installer', { installerPath: resolvedPath, args: installerArgs, elapsedMs: Date.now() - startedAt }) const installerProcess = spawn(resolvedPath, installerArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) installerProcess.stdout?.on('data', (data) => { processOutput += data.toString('utf8') }) installerProcess.stderr?.on('data', (data) => { processOutput += data.toString('utf8') }) installerProcess.on('spawn', () => { debugLog('installer spawned', { pid: installerProcess.pid, elapsedMs: Date.now() - startedAt }) }) installerProcess.on('error', (error) => { console.error(`${DEBUG_PREFIX} installer spawn error:`, error) const message = error?.message || 'Failed to start update installer.' sendProgress({ phase: 'error', percent: null, message }) reject(error) }) installerProcess.on('exit', async (code, signal) => { const logOutput = await readInstallerLog(logPath) const output = [processOutput, logOutput].filter(Boolean).join('\n') debugLog('installer exited', { code, signal, elapsedMs: Date.now() - startedAt, logOutputLength: logOutput.length, processOutputLength: processOutput.length, outputPreview: output.slice(0, 500).replace(/\s+/g, ' ') }) debugLog('keeping install log', { logPath }) if (code !== 0) { const message = getInstallErrorMessage(null, output) sendProgress({ phase: 'error', percent: null, message }) reject(new Error(message)) return } const succeeded = isWindowsInstallSuccessful(output) || (code === 0 && !isWindowsInstallFailed(output)) debugLog('install success evaluation', { succeeded, isSuccessful: isWindowsInstallSuccessful(output), isFailed: isWindowsInstallFailed(output), exitCode: code }) if (!succeeded) { const message = getInstallErrorMessage(null, output) sendProgress({ phase: 'error', percent: null, message }) reject(new Error(message)) return } const { percent, message } = finalParse sendProgress({ phase: 'installing', percent: 100, message: 'Installation complete. Restarting Farm Control...' }) debugLog('installer completed successfully') resolve() }) }) }