farmcontrol-ui/public/winappupdate.js

170 lines
4.2 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()
}
}
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 readInstallerLog = async (logPath) => {
try {
return await fs.readFile(logPath, 'utf8')
} catch {
return ''
}
}
export const launchWindowsInstaller = async (
app,
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(webContents, {
phase: 'installing',
percent: 0,
message: 'Installing update...'
})
await fs.unlink(logPath).catch(() => {})
await sleep(2000)
return new Promise((resolve, reject) => {
let processOutput = ''
const startedAt = Date.now()
const installerArgs = ['/S', `/LOG=${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(webContents, {
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) ||
`Update installer failed with exit code ${code ?? 'unknown'}.`
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
sendProgress(webContents, {
phase: 'installing',
percent: 100,
message: 'Installation complete. Restarting Farm Control...'
})
debugLog('installer completed successfully')
resolve()
})
})
}