Compare commits
No commits in common. "79ee30fd25d990ecc0e73ef0cfb2d0f9745af9d6" and "d418e4b80936e8c56fb29d6f23d5d3acf95bd411" have entirely different histories.
79ee30fd25
...
d418e4b809
@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = {
|
|||||||
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
||||||
},
|
},
|
||||||
win32: {
|
win32: {
|
||||||
extension: '.exe',
|
extension: '.msi',
|
||||||
osMatchers: ['win32', 'win', 'windows']
|
osMatchers: ['win32', 'win', 'windows']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,20 +2,143 @@ import { spawn } from 'child_process'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import process from 'process'
|
||||||
|
|
||||||
const MZ_HEADER = Buffer.from([0x4d, 0x5a])
|
const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])
|
||||||
const DEBUG_PREFIX = '[app-update][win-progress]'
|
const DEBUG_PREFIX = '[app-update][win-progress]'
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
const debugLog = () => {}
|
const debugLog = () => {}
|
||||||
|
|
||||||
const isValidWindowsExecutable = async (filePath) => {
|
const decodeMsiLogBuffer = (buffer) => {
|
||||||
|
if (!buffer?.length) return ''
|
||||||
|
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||||
|
debugLog('decoded MSI log as UTF-16 LE (BOM)')
|
||||||
|
return buffer.subarray(2).toString('utf16le')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = buffer.subarray(0, Math.min(buffer.length, 64))
|
||||||
|
const looksUtf16 =
|
||||||
|
sample.length >= 4 &&
|
||||||
|
sample.filter((byte) => byte === 0).length > sample.length / 4
|
||||||
|
|
||||||
|
if (looksUtf16) {
|
||||||
|
debugLog('decoded MSI log as UTF-16 LE (heuristic)')
|
||||||
|
return buffer.toString('utf16le')
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('decoded MSI log as UTF-8')
|
||||||
|
return buffer.toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMsiActionName = (actionName) => {
|
||||||
|
const humanized = String(actionName)
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (!humanized) return 'Installing update...'
|
||||||
|
|
||||||
|
return `${humanized.charAt(0).toUpperCase()}${humanized.slice(1)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseWindowsInstallerProgress = (output) => {
|
||||||
|
const lines = String(output || '').split(/\r?\n/)
|
||||||
|
let percent = null
|
||||||
|
let message = 'Installing update...'
|
||||||
|
let totalTicks = 0
|
||||||
|
let currentTicks = 0
|
||||||
|
let actionStarts = 0
|
||||||
|
let actionEnds = 0
|
||||||
|
const matchedLines = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./)
|
||||||
|
if (actionStart) {
|
||||||
|
actionStarts += 1
|
||||||
|
message = formatMsiActionName(actionStart[1])
|
||||||
|
matchedLines.push(`action-start:${actionStart[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doingAction = line.match(/Doing action:\s*(.+)$/)
|
||||||
|
if (doingAction && !actionStart) {
|
||||||
|
message = formatMsiActionName(doingAction[1])
|
||||||
|
matchedLines.push(`doing-action:${doingAction[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) {
|
||||||
|
actionEnds += 1
|
||||||
|
matchedLines.push('action-ended')
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressReset = line.match(/^\s*0\s+(\d+)\s+0(?:\s+\d+)?\s*$/)
|
||||||
|
if (progressReset) {
|
||||||
|
totalTicks = Number.parseInt(progressReset[1], 10) || 0
|
||||||
|
currentTicks = 0
|
||||||
|
matchedLines.push(`progress-reset:${totalTicks}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressIncrement = line.match(/^\s*2\s+(\d+)\s*$/)
|
||||||
|
if (progressIncrement) {
|
||||||
|
currentTicks += Number.parseInt(progressIncrement[1], 10) || 0
|
||||||
|
matchedLines.push(`progress-increment:${progressIncrement[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressAddition = line.match(/^\s*3\s+(\d+)\s*$/)
|
||||||
|
if (progressAddition) {
|
||||||
|
totalTicks += Number.parseInt(progressAddition[1], 10) || 0
|
||||||
|
matchedLines.push(`progress-addition:${progressAddition[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/Installation success or error status:\s*0\b/.test(line)) {
|
||||||
|
percent = 100
|
||||||
|
message = 'Installation complete. Restarting Farm Control...'
|
||||||
|
matchedLines.push('install-success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percent !== 100) {
|
||||||
|
if (totalTicks > 0) {
|
||||||
|
percent = Math.min(99, Math.round((currentTicks / totalTicks) * 100))
|
||||||
|
} else if (actionStarts > 0) {
|
||||||
|
percent = Math.min(
|
||||||
|
95,
|
||||||
|
Math.max(5, Math.round((actionEnds / actionStarts) * 90))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percent,
|
||||||
|
message,
|
||||||
|
stats: {
|
||||||
|
lineCount: lines.length,
|
||||||
|
actionStarts,
|
||||||
|
actionEnds,
|
||||||
|
totalTicks,
|
||||||
|
currentTicks,
|
||||||
|
matchedLines: matchedLines.slice(-8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWindowsInstallSuccessful = (output) =>
|
||||||
|
/Installation success or error status:\s*0\b/.test(output) ||
|
||||||
|
/MainEngineThread is returning 0\b/.test(output)
|
||||||
|
|
||||||
|
const isWindowsInstallFailed = (output) =>
|
||||||
|
/Installation success or error status:\s*[1-9]\d*\b/.test(output) ||
|
||||||
|
/MainEngineThread is returning [1-9]\d*\b/.test(output)
|
||||||
|
|
||||||
|
const isValidMsiPackage = async (filePath) => {
|
||||||
const handle = await fs.open(filePath, 'r')
|
const handle = await fs.open(filePath, 'r')
|
||||||
try {
|
try {
|
||||||
const header = Buffer.alloc(MZ_HEADER.length)
|
const header = Buffer.alloc(MSI_OLE_HEADER.length)
|
||||||
await handle.read(header, 0, header.length, 0)
|
await handle.read(header, 0, header.length, 0)
|
||||||
return header.equals(MZ_HEADER)
|
return header.equals(MSI_OLE_HEADER)
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close()
|
await handle.close()
|
||||||
}
|
}
|
||||||
@ -35,6 +158,7 @@ const prepareInstallerPath = async (installerPath) => {
|
|||||||
const stablePath = path.join(updateDir, fileName)
|
const stablePath = path.join(updateDir, fileName)
|
||||||
await fs.copyFile(installerPath, stablePath)
|
await fs.copyFile(installerPath, stablePath)
|
||||||
|
|
||||||
|
// Resolve to a canonical long path. Short 8.3 paths (e.g. ADMINI~1) break msiexec.
|
||||||
const resolvedPath = await fs.realpath(stablePath)
|
const resolvedPath = await fs.realpath(stablePath)
|
||||||
const stats = await fs.stat(resolvedPath)
|
const stats = await fs.stat(resolvedPath)
|
||||||
|
|
||||||
@ -42,20 +166,118 @@ const prepareInstallerPath = async (installerPath) => {
|
|||||||
throw new Error('Update installer file is missing or empty.')
|
throw new Error('Update installer file is missing or empty.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isValidWindowsExecutable(resolvedPath))) {
|
if (!(await isValidMsiPackage(resolvedPath))) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.'
|
'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvedPath
|
return resolvedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const readInstallerLog = async (logPath) => {
|
const startWindowsInstallerProgressWatch = (
|
||||||
|
logPath,
|
||||||
|
webContents,
|
||||||
|
sendProgress
|
||||||
|
) => {
|
||||||
|
let installerOutput = ''
|
||||||
|
let lastLogSize = 0
|
||||||
|
let lastPercent = null
|
||||||
|
let lastMessage = null
|
||||||
|
let pollCount = 0
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
pollCount += 1
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(logPath, 'utf8')
|
const stat = await fs.stat(logPath)
|
||||||
} catch {
|
if (stat.size === 0) {
|
||||||
return ''
|
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(webContents, {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,42 +304,71 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
await fs.unlink(logPath).catch(() => {})
|
await fs.unlink(logPath).catch(() => {})
|
||||||
|
|
||||||
|
// Allow file handles from the download/copy to settle before msiexec opens the MSI.
|
||||||
await sleep(2000)
|
await sleep(2000)
|
||||||
|
|
||||||
|
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
||||||
|
logPath,
|
||||||
|
webContents,
|
||||||
|
sendProgress
|
||||||
|
)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let processOutput = ''
|
let processOutput = ''
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
const installerArgs = ['/S', `/LOG=${logPath}`]
|
const installerArgs = [
|
||||||
|
'/i',
|
||||||
|
resolvedPath,
|
||||||
|
'/qn',
|
||||||
|
'/norestart',
|
||||||
|
'/L*v!',
|
||||||
|
logPath
|
||||||
|
]
|
||||||
|
|
||||||
debugLog('spawning NSIS installer', {
|
debugLog('spawning msiexec', {
|
||||||
installerPath: resolvedPath,
|
|
||||||
args: installerArgs,
|
args: installerArgs,
|
||||||
elapsedMs: Date.now() - startedAt
|
elapsedMs: Date.now() - startedAt
|
||||||
})
|
})
|
||||||
|
|
||||||
const installerProcess = spawn(resolvedPath, installerArgs, {
|
const installerProcess = spawn('msiexec.exe', installerArgs, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.stdout?.on('data', (data) => {
|
installerProcess.stdout?.on('data', (data) => {
|
||||||
processOutput += data.toString('utf8')
|
const text = data.toString('utf16le')
|
||||||
|
processOutput += text
|
||||||
|
debugLog('msiexec stdout chunk', {
|
||||||
|
length: text.length,
|
||||||
|
preview: text.slice(0, 200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.stderr?.on('data', (data) => {
|
installerProcess.stderr?.on('data', (data) => {
|
||||||
processOutput += data.toString('utf8')
|
const text = data.toString('utf16le')
|
||||||
|
processOutput += text
|
||||||
|
debugLog('msiexec stderr chunk', {
|
||||||
|
length: text.length,
|
||||||
|
preview: text.slice(0, 200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.on('spawn', () => {
|
installerProcess.on('spawn', () => {
|
||||||
debugLog('installer spawned', {
|
debugLog('msiexec spawned', {
|
||||||
pid: installerProcess.pid,
|
pid: installerProcess.pid,
|
||||||
elapsedMs: Date.now() - startedAt
|
elapsedMs: Date.now() - startedAt
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.on('error', (error) => {
|
installerProcess.on('error', async (error) => {
|
||||||
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
|
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
|
||||||
|
const watchedOutput = await stopProgressWatch()
|
||||||
|
|
||||||
|
debugLog('installer spawn failed', {
|
||||||
|
watchedOutputLength: watchedOutput.length,
|
||||||
|
processOutputLength: processOutput.length
|
||||||
|
})
|
||||||
|
|
||||||
const message = error?.message || 'Failed to start update installer.'
|
const message = error?.message || 'Failed to start update installer.'
|
||||||
sendProgress(webContents, {
|
sendProgress(webContents, {
|
||||||
@ -129,24 +380,24 @@ export const launchWindowsInstaller = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.on('exit', async (code, signal) => {
|
installerProcess.on('exit', async (code, signal) => {
|
||||||
const logOutput = await readInstallerLog(logPath)
|
const watchedOutput = await stopProgressWatch()
|
||||||
const output = [processOutput, logOutput].filter(Boolean).join('\n')
|
const output = watchedOutput || processOutput
|
||||||
|
const finalParse = parseWindowsInstallerProgress(output)
|
||||||
|
|
||||||
debugLog('installer exited', {
|
debugLog('msiexec exited', {
|
||||||
code,
|
code,
|
||||||
signal,
|
signal,
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
logOutputLength: logOutput.length,
|
watchedOutputLength: watchedOutput.length,
|
||||||
processOutputLength: processOutput.length,
|
processOutputLength: processOutput.length,
|
||||||
|
parsed: finalParse.stats,
|
||||||
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
|
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
debugLog('keeping install log', { logPath })
|
debugLog('keeping install log', { logPath })
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const message =
|
const message = getInstallErrorMessage(null, output)
|
||||||
getInstallErrorMessage(null, output) ||
|
|
||||||
`Update installer failed with exit code ${code ?? 'unknown'}.`
|
|
||||||
sendProgress(webContents, {
|
sendProgress(webContents, {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
@ -156,10 +407,34 @@ export const launchWindowsInstaller = async (
|
|||||||
return
|
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(webContents, {
|
||||||
|
phase: 'error',
|
||||||
|
percent: null,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
reject(new Error(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { percent, message } = finalParse
|
||||||
|
|
||||||
sendProgress(webContents, {
|
sendProgress(webContents, {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: 100,
|
percent: percent ?? 100,
|
||||||
message: 'Installation complete. Restarting Farm Control...'
|
message: message || 'Installation complete. Restarting Farm Control...'
|
||||||
})
|
})
|
||||||
|
|
||||||
debugLog('installer completed successfully')
|
debugLog('installer completed successfully')
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = {
|
|||||||
osMatchers: ["darwin", "mac", "macos", "osx"],
|
osMatchers: ["darwin", "mac", "macos", "osx"],
|
||||||
},
|
},
|
||||||
win32: {
|
win32: {
|
||||||
extension: ".exe",
|
extension: ".msi",
|
||||||
osMatchers: ["win32", "win", "windows"],
|
osMatchers: ["win32", "win", "windows"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,20 +2,143 @@ import { spawn } from 'child_process'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import process from 'process'
|
||||||
|
|
||||||
const MZ_HEADER = Buffer.from([0x4d, 0x5a])
|
const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])
|
||||||
const DEBUG_PREFIX = '[app-update][win-progress]'
|
const DEBUG_PREFIX = '[app-update][win-progress]'
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
const debugLog = () => {}
|
const debugLog = () => {}
|
||||||
|
|
||||||
const isValidWindowsExecutable = async (filePath) => {
|
const decodeMsiLogBuffer = (buffer) => {
|
||||||
|
if (!buffer?.length) return ''
|
||||||
|
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||||
|
debugLog('decoded MSI log as UTF-16 LE (BOM)')
|
||||||
|
return buffer.subarray(2).toString('utf16le')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = buffer.subarray(0, Math.min(buffer.length, 64))
|
||||||
|
const looksUtf16 =
|
||||||
|
sample.length >= 4 &&
|
||||||
|
sample.filter((byte) => byte === 0).length > sample.length / 4
|
||||||
|
|
||||||
|
if (looksUtf16) {
|
||||||
|
debugLog('decoded MSI log as UTF-16 LE (heuristic)')
|
||||||
|
return buffer.toString('utf16le')
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog('decoded MSI log as UTF-8')
|
||||||
|
return buffer.toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMsiActionName = (actionName) => {
|
||||||
|
const humanized = String(actionName)
|
||||||
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (!humanized) return 'Installing update...'
|
||||||
|
|
||||||
|
return `${humanized.charAt(0).toUpperCase()}${humanized.slice(1)}...`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseWindowsInstallerProgress = (output) => {
|
||||||
|
const lines = String(output || '').split(/\r?\n/)
|
||||||
|
let percent = null
|
||||||
|
let message = 'Installing update...'
|
||||||
|
let totalTicks = 0
|
||||||
|
let currentTicks = 0
|
||||||
|
let actionStarts = 0
|
||||||
|
let actionEnds = 0
|
||||||
|
const matchedLines = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./)
|
||||||
|
if (actionStart) {
|
||||||
|
actionStarts += 1
|
||||||
|
message = formatMsiActionName(actionStart[1])
|
||||||
|
matchedLines.push(`action-start:${actionStart[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doingAction = line.match(/Doing action:\s*(.+)$/)
|
||||||
|
if (doingAction && !actionStart) {
|
||||||
|
message = formatMsiActionName(doingAction[1])
|
||||||
|
matchedLines.push(`doing-action:${doingAction[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) {
|
||||||
|
actionEnds += 1
|
||||||
|
matchedLines.push('action-ended')
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressReset = line.match(/^\s*0\s+(\d+)\s+0(?:\s+\d+)?\s*$/)
|
||||||
|
if (progressReset) {
|
||||||
|
totalTicks = Number.parseInt(progressReset[1], 10) || 0
|
||||||
|
currentTicks = 0
|
||||||
|
matchedLines.push(`progress-reset:${totalTicks}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressIncrement = line.match(/^\s*2\s+(\d+)\s*$/)
|
||||||
|
if (progressIncrement) {
|
||||||
|
currentTicks += Number.parseInt(progressIncrement[1], 10) || 0
|
||||||
|
matchedLines.push(`progress-increment:${progressIncrement[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressAddition = line.match(/^\s*3\s+(\d+)\s*$/)
|
||||||
|
if (progressAddition) {
|
||||||
|
totalTicks += Number.parseInt(progressAddition[1], 10) || 0
|
||||||
|
matchedLines.push(`progress-addition:${progressAddition[1]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/Installation success or error status:\s*0\b/.test(line)) {
|
||||||
|
percent = 100
|
||||||
|
message = 'Installation complete. Restarting Farm Control...'
|
||||||
|
matchedLines.push('install-success')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percent !== 100) {
|
||||||
|
if (totalTicks > 0) {
|
||||||
|
percent = Math.min(99, Math.round((currentTicks / totalTicks) * 100))
|
||||||
|
} else if (actionStarts > 0) {
|
||||||
|
percent = Math.min(
|
||||||
|
95,
|
||||||
|
Math.max(5, Math.round((actionEnds / actionStarts) * 90))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percent,
|
||||||
|
message,
|
||||||
|
stats: {
|
||||||
|
lineCount: lines.length,
|
||||||
|
actionStarts,
|
||||||
|
actionEnds,
|
||||||
|
totalTicks,
|
||||||
|
currentTicks,
|
||||||
|
matchedLines: matchedLines.slice(-8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWindowsInstallSuccessful = (output) =>
|
||||||
|
/Installation success or error status:\s*0\b/.test(output) ||
|
||||||
|
/MainEngineThread is returning 0\b/.test(output)
|
||||||
|
|
||||||
|
const isWindowsInstallFailed = (output) =>
|
||||||
|
/Installation success or error status:\s*[1-9]\d*\b/.test(output) ||
|
||||||
|
/MainEngineThread is returning [1-9]\d*\b/.test(output)
|
||||||
|
|
||||||
|
const isValidMsiPackage = async (filePath) => {
|
||||||
const handle = await fs.open(filePath, 'r')
|
const handle = await fs.open(filePath, 'r')
|
||||||
try {
|
try {
|
||||||
const header = Buffer.alloc(MZ_HEADER.length)
|
const header = Buffer.alloc(MSI_OLE_HEADER.length)
|
||||||
await handle.read(header, 0, header.length, 0)
|
await handle.read(header, 0, header.length, 0)
|
||||||
return header.equals(MZ_HEADER)
|
return header.equals(MSI_OLE_HEADER)
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close()
|
await handle.close()
|
||||||
}
|
}
|
||||||
@ -35,6 +158,7 @@ export const prepareInstallerPath = async (installerPath) => {
|
|||||||
const stablePath = path.join(updateDir, fileName)
|
const stablePath = path.join(updateDir, fileName)
|
||||||
await fs.copyFile(installerPath, stablePath)
|
await fs.copyFile(installerPath, stablePath)
|
||||||
|
|
||||||
|
// Resolve to a canonical long path. Short 8.3 paths (e.g. ADMINI~1) break msiexec.
|
||||||
const resolvedPath = await fs.realpath(stablePath)
|
const resolvedPath = await fs.realpath(stablePath)
|
||||||
const stats = await fs.stat(resolvedPath)
|
const stats = await fs.stat(resolvedPath)
|
||||||
|
|
||||||
@ -42,20 +166,114 @@ export const prepareInstallerPath = async (installerPath) => {
|
|||||||
throw new Error('Update installer file is missing or empty.')
|
throw new Error('Update installer file is missing or empty.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await isValidWindowsExecutable(resolvedPath))) {
|
if (!(await isValidMsiPackage(resolvedPath))) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.'
|
'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvedPath
|
return resolvedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const readInstallerLog = async (logPath) => {
|
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
|
||||||
|
let installerOutput = ''
|
||||||
|
let lastLogSize = 0
|
||||||
|
let lastPercent = null
|
||||||
|
let lastMessage = null
|
||||||
|
let pollCount = 0
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
pollCount += 1
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(logPath, 'utf8')
|
const stat = await fs.stat(logPath)
|
||||||
} catch {
|
if (stat.size === 0) {
|
||||||
return ''
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +292,7 @@ export const launchWindowsInstaller = async (
|
|||||||
logPath
|
logPath
|
||||||
})
|
})
|
||||||
|
|
||||||
sendProgress({
|
sendProgress( {
|
||||||
phase: 'installing',
|
phase: 'installing',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
message: 'Installing update...'
|
message: 'Installing update...'
|
||||||
@ -82,45 +300,73 @@ export const launchWindowsInstaller = async (
|
|||||||
|
|
||||||
await fs.unlink(logPath).catch(() => {})
|
await fs.unlink(logPath).catch(() => {})
|
||||||
|
|
||||||
|
// Allow file handles from the download/copy to settle before msiexec opens the MSI.
|
||||||
await sleep(2000)
|
await sleep(2000)
|
||||||
|
|
||||||
|
const stopProgressWatch = startWindowsInstallerProgressWatch(
|
||||||
|
logPath,
|
||||||
|
sendProgress
|
||||||
|
)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let processOutput = ''
|
let processOutput = ''
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
const installerArgs = ['/S', `/LOG=${logPath}`]
|
const installerArgs = [
|
||||||
|
'/i',
|
||||||
|
resolvedPath,
|
||||||
|
'/qn',
|
||||||
|
'/norestart',
|
||||||
|
'/L*v!',
|
||||||
|
logPath
|
||||||
|
]
|
||||||
|
|
||||||
debugLog('spawning NSIS installer', {
|
debugLog('spawning msiexec', {
|
||||||
installerPath: resolvedPath,
|
|
||||||
args: installerArgs,
|
args: installerArgs,
|
||||||
elapsedMs: Date.now() - startedAt
|
elapsedMs: Date.now() - startedAt
|
||||||
})
|
})
|
||||||
|
|
||||||
const installerProcess = spawn(resolvedPath, installerArgs, {
|
const installerProcess = spawn('msiexec.exe', installerArgs, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.stdout?.on('data', (data) => {
|
installerProcess.stdout?.on('data', (data) => {
|
||||||
processOutput += data.toString('utf8')
|
const text = data.toString('utf16le')
|
||||||
|
processOutput += text
|
||||||
|
debugLog('msiexec stdout chunk', {
|
||||||
|
length: text.length,
|
||||||
|
preview: text.slice(0, 200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.stderr?.on('data', (data) => {
|
installerProcess.stderr?.on('data', (data) => {
|
||||||
processOutput += data.toString('utf8')
|
const text = data.toString('utf16le')
|
||||||
|
processOutput += text
|
||||||
|
debugLog('msiexec stderr chunk', {
|
||||||
|
length: text.length,
|
||||||
|
preview: text.slice(0, 200)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.on('spawn', () => {
|
installerProcess.on('spawn', () => {
|
||||||
debugLog('installer spawned', {
|
debugLog('msiexec spawned', {
|
||||||
pid: installerProcess.pid,
|
pid: installerProcess.pid,
|
||||||
elapsedMs: Date.now() - startedAt
|
elapsedMs: Date.now() - startedAt
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.on('error', (error) => {
|
installerProcess.on('error', async (error) => {
|
||||||
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
|
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
|
||||||
|
const watchedOutput = await stopProgressWatch()
|
||||||
|
|
||||||
|
debugLog('installer spawn failed', {
|
||||||
|
watchedOutputLength: watchedOutput.length,
|
||||||
|
processOutputLength: processOutput.length
|
||||||
|
})
|
||||||
|
|
||||||
const message = error?.message || 'Failed to start update installer.'
|
const message = error?.message || 'Failed to start update installer.'
|
||||||
sendProgress({
|
sendProgress( {
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -129,25 +375,25 @@ export const launchWindowsInstaller = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
installerProcess.on('exit', async (code, signal) => {
|
installerProcess.on('exit', async (code, signal) => {
|
||||||
const logOutput = await readInstallerLog(logPath)
|
const watchedOutput = await stopProgressWatch()
|
||||||
const output = [processOutput, logOutput].filter(Boolean).join('\n')
|
const output = watchedOutput || processOutput
|
||||||
|
const finalParse = parseWindowsInstallerProgress(output)
|
||||||
|
|
||||||
debugLog('installer exited', {
|
debugLog('msiexec exited', {
|
||||||
code,
|
code,
|
||||||
signal,
|
signal,
|
||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
logOutputLength: logOutput.length,
|
watchedOutputLength: watchedOutput.length,
|
||||||
processOutputLength: processOutput.length,
|
processOutputLength: processOutput.length,
|
||||||
|
parsed: finalParse.stats,
|
||||||
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
|
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
debugLog('keeping install log', { logPath })
|
debugLog('keeping install log', { logPath })
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const message =
|
const message = getInstallErrorMessage(null, output)
|
||||||
getInstallErrorMessage(null, output) ||
|
sendProgress( {
|
||||||
`Update installer failed with exit code ${code ?? 'unknown'}.`
|
|
||||||
sendProgress({
|
|
||||||
phase: 'error',
|
phase: 'error',
|
||||||
percent: null,
|
percent: null,
|
||||||
message
|
message
|
||||||
@ -156,10 +402,34 @@ export const launchWindowsInstaller = async (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sendProgress({
|
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',
|
phase: 'installing',
|
||||||
percent: 100,
|
percent: percent ?? 100,
|
||||||
message: 'Installation complete. Restarting Farm Control...'
|
message: message || 'Installation complete. Restarting Farm Control...'
|
||||||
})
|
})
|
||||||
|
|
||||||
debugLog('installer completed successfully')
|
debugLog('installer completed successfully')
|
||||||
|
|||||||
@ -184,6 +184,10 @@ function applyWindowsStartupWindowState(window) {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.maximize?.()
|
window.maximize?.()
|
||||||
|
handleWindowsWindowChange(window)
|
||||||
|
setTimeout(() => syncWindowsWebviewLayout(window), 0)
|
||||||
|
setTimeout(() => syncWindowsWebviewLayout(window), 100)
|
||||||
|
setTimeout(broadcastWindowState, 100)
|
||||||
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
|
}, WINDOWS_STARTUP_MAXIMIZE_DELAY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,6 @@ export function clampWindowToWorkArea(window) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
window.setFrame(workArea.x - 8, workArea.y, workArea.width + 16, workArea.height + 8)
|
window.setFrame(workArea.x, workArea.y, workArea.width, workArea.height)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user