All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
298 lines
7.5 KiB
JavaScript
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()
|
|
})
|
|
})
|
|
}
|