farmcontrol-ui/public/winappupdate.js
Tom Butcher d5b9714630
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Merge branch 'electrobun' of https://git.tombutcher.work/tom/farmcontrol-ui into electrobun
2026-08-03 22:58:46 +01:00

298 lines
7.5 KiB
JavaScript

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