Enhance Windows installer with improved logging and installation progress tracking
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added support for silent installation mode.
- Implemented detailed progress logging during installation phases, including status updates and percentage completion.
- Refactored the handling of previous installations to ensure smoother updates and in-app upgrade processes.
- Updated error handling for installation failures, providing clearer feedback to users.
This commit is contained in:
Tom Butcher 2026-08-03 23:54:23 +01:00
parent 2383d14a80
commit 4fdf3a233d
4 changed files with 268 additions and 282 deletions

View File

@ -23,6 +23,7 @@ OutFile "${OUTFILE}"
InstallDir "$LOCALAPPDATA\Programs\Farm Control" InstallDir "$LOCALAPPDATA\Programs\Farm Control"
InstallDirRegKey HKCU "Software\Tom Butcher\Farm Control" "InstallDir" InstallDirRegKey HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
RequestExecutionLevel user RequestExecutionLevel user
SilentInstall normal
!define MUI_ABORTWARNING !define MUI_ABORTWARNING
@ -45,19 +46,49 @@ BrandingText "Farm Control v${VERSION}-b${BUILD_NUMBER} Installer"
!insertmacro MUI_LANGUAGE "English" !insertmacro MUI_LANGUAGE "English"
Function .onInit Function .onInit
!insertmacro initProgressLog
${If} ${Silent} ${If} ${Silent}
SetAutoClose true SetAutoClose true
${EndIf} ${EndIf}
!insertmacro uninstallPreviousFarmControl
!insertmacro progressPhase "Starting installation"
!insertmacro progressStatus "Starting Farm Control installation..."
!insertmacro progressPercent 5
; In-app updates keep the running process alive for progress UI.
; Interactive / silent fresh installs still remove the previous version first.
${If} $IsInAppUpdate == "1"
!insertmacro prepareInPlaceUpdate
!insertmacro progressPercent 15
${Else}
!insertmacro uninstallPreviousFarmControl
!insertmacro progressPercent 20
${EndIf}
FunctionEnd
Function .onInstFailed
!insertmacro progressFailure "Installation failed."
FunctionEnd FunctionEnd
Section "Farm Control" SecMain Section "Farm Control" SecMain
SectionIn RO SectionIn RO
!insertmacro progressPhase "Copying application files"
!insertmacro progressStatus "Copying application files..."
!insertmacro progressPercent 35
SetOutPath $INSTDIR SetOutPath $INSTDIR
SetOverwrite try
File /r "${APP_SOURCE_DIR}\*.*" File /r "${APP_SOURCE_DIR}\*.*"
!insertmacro progressPercent 75
!insertmacro customInstall !insertmacro customInstall
!insertmacro progressPhase "Finalizing installation"
!insertmacro progressStatus "Writing uninstall information..."
!insertmacro progressPercent 90
WriteRegStr HKCU "Software\Tom Butcher\Farm Control" "InstallDir" $INSTDIR WriteRegStr HKCU "Software\Tom Butcher\Farm Control" "InstallDir" $INSTDIR
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"DisplayName" "Farm Control" "DisplayName" "Farm Control"
@ -73,6 +104,8 @@ Section "Farm Control" SecMain
"NoRepair" 1 "NoRepair" 1
WriteUninstaller "$INSTDIR\Uninstall.exe" WriteUninstaller "$INSTDIR\Uninstall.exe"
!insertmacro progressSuccess
SectionEnd SectionEnd
Section "Uninstall" Section "Uninstall"

View File

@ -1,8 +1,131 @@
!macro quitFarmControl !include "FileFunc.nsh"
!insertmacro GetParameters
!insertmacro GetOptions
Var ProgressLogFile
Var IsInAppUpdate
!macro initProgressLog
StrCpy $ProgressLogFile ""
StrCpy $IsInAppUpdate "0"
${GetParameters} $R9
ClearErrors
${GetOptions} $R9 "/UPDATE" $R8
${IfNot} ${Errors}
StrCpy $IsInAppUpdate "1"
${EndIf}
; Prefer env var so paths with spaces are reliable; /LOG= remains supported.
ReadEnvStr $ProgressLogFile "FARMCONTROL_INSTALL_LOG"
${If} $ProgressLogFile == ""
ClearErrors
${GetOptions} $R9 "/LOG=" $ProgressLogFile
${If} ${Errors}
StrCpy $ProgressLogFile ""
${EndIf}
${EndIf}
${If} $ProgressLogFile != ""
; Strip surrounding quotes from /LOG="C:\path with spaces\log.log"
StrCpy $R8 $ProgressLogFile 1
${If} $R8 == '"'
StrCpy $ProgressLogFile $ProgressLogFile "" 1
StrLen $R8 $ProgressLogFile
IntOp $R8 $R8 - 1
${If} $R8 > 0
StrCpy $ProgressLogFile $ProgressLogFile $R8
${EndIf}
${EndIf}
; Truncate any previous log so progress starts clean.
Push $0
FileOpen $0 "$ProgressLogFile" w
${If} $0 != ""
FileClose $0
${EndIf}
Pop $0
${EndIf}
!macroend
; Open/append/close each line so the updater can read the log concurrently
; (avoids an exclusive lock for the whole install; LogEx SHARE_READ is an alternative).
!macro progressLog line
DetailPrint "${line}"
${If} $ProgressLogFile != ""
Push $0
FileOpen $0 "$ProgressLogFile" a
${If} $0 != ""
FileSeek $0 0 END
FileWrite $0 "${line}$\r$\n"
FileClose $0
${EndIf}
Pop $0
${EndIf}
!macroend
!macro progressPercent percent
!insertmacro progressLog "installer:%${percent}"
!macroend
!macro progressPhase phase
!insertmacro progressLog "installer:PHASE:${phase}"
!macroend
!macro progressStatus status
!insertmacro progressLog "installer:STATUS:${status}"
!macroend
!macro progressSuccess
!insertmacro progressPercent 100
!insertmacro progressStatus "Installation complete. Restarting Farm Control..."
!insertmacro progressLog "installer: The install was successful."
!macroend
!macro progressFailure message
!insertmacro progressLog "installer:STATUS:${message}"
!insertmacro progressLog "installer: The install failed."
!macroend
!macro quitFarmControl
!insertmacro progressPhase "Stopping Farm Control"
!insertmacro progressStatus "Stopping running Farm Control processes..."
DetailPrint "Stopping running Farm Control processes..." DetailPrint "Stopping running Farm Control processes..."
ExecWait 'taskkill /F /IM launcher.exe /T' $R0 ExecWait 'taskkill /F /IM launcher.exe /T' $R0
!macroend !macroend
; Windows allows renaming open executables; move locked binaries aside so File can replace them.
!macro moveAsideIfPresent filePath
${If} ${FileExists} "${filePath}"
Delete "${filePath}.old"
ClearErrors
Rename "${filePath}" "${filePath}.old"
ClearErrors
${EndIf}
!macroend
; Prefer renaming the whole bin dir (works even with open files inside).
!macro prepareInPlaceUpdate
!insertmacro progressPhase "Preparing update"
!insertmacro progressStatus "Preparing files for update..."
${If} ${FileExists} "$INSTDIR\bin.old"
RMDir /r "$INSTDIR\bin.old"
${EndIf}
${If} ${FileExists} "$INSTDIR\bin"
ClearErrors
Rename "$INSTDIR\bin" "$INSTDIR\bin.old"
${If} ${Errors}
!insertmacro moveAsideIfPresent "$INSTDIR\bin\launcher.exe"
!insertmacro moveAsideIfPresent "$INSTDIR\bin\bun.exe"
!insertmacro moveAsideIfPresent "$INSTDIR\bin\bspatch.exe"
!insertmacro moveAsideIfPresent "$INSTDIR\bin\zig-zstd.exe"
${EndIf}
${EndIf}
!macroend
!macro uninstallPreviousFarmControl !macro uninstallPreviousFarmControl
ClearErrors ClearErrors
ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString" ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
@ -34,10 +157,13 @@
run_uninstall: run_uninstall:
IfFileExists $R0 0 done_uninstall IfFileExists $R0 0 done_uninstall
!insertmacro progressPhase "Removing previous version"
!insertmacro progressStatus "Removing previous Farm Control installation..."
DetailPrint "Removing previous Farm Control installation..." DetailPrint "Removing previous Farm Control installation..."
!insertmacro quitFarmControl !insertmacro quitFarmControl
ExecWait '$R0 /S' $R1 ExecWait '"$R0" /S' $R1
DetailPrint "Previous installation removed (exit code: $R1)" DetailPrint "Previous installation removed (exit code: $R1)"
!insertmacro progressLog "installer:STATUS:Previous installation removed"
done_uninstall: done_uninstall:
!macroend !macroend
@ -66,7 +192,18 @@
RMDir "$SMPROGRAMS\Farm Control" RMDir "$SMPROGRAMS\Farm Control"
!macroend !macroend
!macro cleanupUpdateBackups
; Best-effort; may fail while the previous process still holds handles.
RMDir /r "$INSTDIR\bin.old"
Delete "$INSTDIR\bin\launcher.exe.old"
Delete "$INSTDIR\bin\bun.exe.old"
Delete "$INSTDIR\bin\bspatch.exe.old"
Delete "$INSTDIR\bin\zig-zstd.exe.old"
!macroend
!macro customInstall !macro customInstall
!insertmacro progressPhase "Configuring Farm Control"
!insertmacro progressStatus "Registering farmcontrol URI handler..."
DetailPrint "Register farmcontrol URI Handler" DetailPrint "Register farmcontrol URI Handler"
DeleteRegKey HKCU "Software\Classes\farmcontrol" DeleteRegKey HKCU "Software\Classes\farmcontrol"
WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol" WriteRegStr HKCU "Software\Classes\farmcontrol" "" "URL:farmcontrol"
@ -76,9 +213,15 @@
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" "" WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open" "" ""
WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\bun.exe" "$INSTDIR\bin\deeplink.js" "%1"' WriteRegStr HKCU "Software\Classes\farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\bun.exe" "$INSTDIR\bin\deeplink.js" "%1"'
!insertmacro progressStatus "Creating shortcuts..."
DetailPrint "Creating shortcuts" DetailPrint "Creating shortcuts"
!insertmacro createDesktopShortcut !insertmacro createDesktopShortcut
!insertmacro createStartMenuShortcut !insertmacro createStartMenuShortcut
; Don't remove bin.old during an in-app update the running process still uses it.
${If} $IsInAppUpdate != "1"
!insertmacro cleanupUpdateBackups
${EndIf}
!macroend !macroend
!macro customUnInstall !macro customUnInstall

View File

@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = {
osMatchers: ["darwin", "mac", "macos", "osx"], osMatchers: ["darwin", "mac", "macos", "osx"],
}, },
win32: { win32: {
extension: ".msi", extension: ".exe",
osMatchers: ["win32", "win", "windows"], osMatchers: ["win32", "win", "windows"],
}, },
}; };
@ -107,7 +107,7 @@ const getInstallErrorMessage = (error, output = "") => {
/forbidden by system policy/i.test(combined) || /forbidden by system policy/i.test(combined) ||
/Non-assigned apps are disabled/i.test(combined) /Non-assigned apps are disabled/i.test(combined)
) { ) {
return "Update installation was blocked by Windows Installer policy."; return "Update installation was blocked by system policy.";
} }
return combined || "Failed to install update."; return combined || "Failed to install update.";

View File

@ -2,143 +2,53 @@ 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 MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) const PE_MZ_HEADER = Buffer.from([0x4d, 0x5a]) // "MZ"
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 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 parseWindowsInstallerProgress = (output) => {
const lines = String(output || '').split(/\r?\n/) const lines = String(output || '').split(/\r?\n/)
let percent = null let percent = null
let message = 'Installing update...' let message = 'Installing update...'
let totalTicks = 0
let currentTicks = 0
let actionStarts = 0
let actionEnds = 0
const matchedLines = []
for (const line of lines) { for (const line of lines) {
const actionStart = line.match(/^Action start \d{2}:\d{2}:\d{2}: (.+?)\./) if (line.startsWith('installer:PHASE:')) {
if (actionStart) { message = line.slice('installer:PHASE:'.length).trim() || message
actionStarts += 1 } else if (line.startsWith('installer:STATUS:')) {
message = formatMsiActionName(actionStart[1]) const status = line.slice('installer:STATUS:'.length).trim()
matchedLines.push(`action-start:${actionStart[1]}`) if (status) message = status
} } else if (line.startsWith('installer:%')) {
const value = Number.parseFloat(line.slice('installer:%'.length))
const doingAction = line.match(/Doing action:\s*(.+)$/) if (Number.isFinite(value)) {
if (doingAction && !actionStart) { percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
message = formatMsiActionName(doingAction[1]) }
matchedLines.push(`doing-action:${doingAction[1]}`) } else if (
} line.startsWith('installer: ') &&
!line.startsWith('installer:PHASE:') &&
if (/^Action ended \d{2}:\d{2}:\d{2}: .+?\. Return value \d+\./.test(line)) { !line.startsWith('installer:STATUS:') &&
actionEnds += 1 !line.startsWith('installer:%')
matchedLines.push('action-ended') ) {
} const text = line.slice('installer: '.length).trim()
if (text) message = text
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) { return { percent, message }
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) => const isWindowsInstallSuccessful = (output) =>
/Installation success or error status:\s*0\b/.test(output) || /installer: The install was successful\./i.test(output)
/MainEngineThread is returning 0\b/.test(output)
const isWindowsInstallFailed = (output) => const isWindowsInstallFailed = (output) =>
/Installation success or error status:\s*[1-9]\d*\b/.test(output) || /installer: The install failed/i.test(output)
/MainEngineThread is returning [1-9]\d*\b/.test(output)
const isValidMsiPackage = async (filePath) => { const isValidNsisInstaller = async (filePath) => {
const handle = await fs.open(filePath, 'r') const handle = await fs.open(filePath, 'r')
try { try {
const header = Buffer.alloc(MSI_OLE_HEADER.length) const header = Buffer.alloc(PE_MZ_HEADER.length)
await handle.read(header, 0, header.length, 0) await handle.read(header, 0, header.length, 0)
return header.equals(MSI_OLE_HEADER) return header.equals(PE_MZ_HEADER)
} finally { } finally {
await handle.close() await handle.close()
} }
@ -158,7 +68,6 @@ 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)
@ -166,9 +75,9 @@ 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 isValidMsiPackage(resolvedPath))) { if (!(await isValidNsisInstaller(resolvedPath))) {
throw new Error( throw new Error(
'Downloaded update is not a valid Windows Installer package. The file may be corrupted or incomplete.' 'Downloaded update is not a valid Windows installer. The file may be corrupted or incomplete.'
) )
} }
@ -177,102 +86,53 @@ export const prepareInstallerPath = async (installerPath) => {
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => { const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
let installerOutput = '' let installerOutput = ''
let lastLogSize = 0 let offset = 0
let lastPercent = null let lastPercent = null
let lastMessage = null let lastMessage = null
let pollCount = 0
const poll = async () => { const poll = async () => {
pollCount += 1
try { try {
const stat = await fs.stat(logPath) const stat = await fs.stat(logPath)
if (stat.size === 0) { if (stat.size <= offset) 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') const handle = await fs.open(logPath, 'r')
try { try {
await handle.read(buffer, 0, stat.size, 0) const buffer = Buffer.alloc(stat.size - offset)
await handle.read(buffer, 0, buffer.length, offset)
offset = stat.size
installerOutput += buffer.toString('utf8')
const { percent, message } = parseWindowsInstallerProgress(installerOutput)
const resolvedMessage = message || 'Installing update...'
if (percent !== lastPercent || resolvedMessage !== lastMessage) {
lastPercent = percent
lastMessage = resolvedMessage
sendProgress({
phase: 'installing',
percent,
message: resolvedMessage
})
}
} finally { } finally {
await handle.close() 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) { } catch (error) {
if (error?.code === 'ENOENT') { if (error?.code !== 'ENOENT') {
debugLog(`poll #${pollCount}: log file not created yet`, { logPath }) console.error('[app-update] installer log poll error:', error)
return
} }
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
} }
} }
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
poll().catch((error) => { poll().catch((error) => {
console.error(`${DEBUG_PREFIX} installer log poll error:`, error) console.error('[app-update] installer log poll error:', error)
}) })
}, 300) }, 300)
return async () => { return async () => {
clearInterval(intervalId) clearInterval(intervalId)
await poll() await poll()
debugLog('stopped progress watch', {
logPath,
finalSize: lastLogSize,
textLength: installerOutput.length,
pollCount
})
return installerOutput return installerOutput
} }
} }
@ -286,13 +146,7 @@ export const launchWindowsInstaller = async (
const resolvedPath = await prepareInstallerPath(installerPath) const resolvedPath = await prepareInstallerPath(installerPath)
const logPath = path.join(path.dirname(resolvedPath), 'install.log') const logPath = path.join(path.dirname(resolvedPath), 'install.log')
debugLog('prepared installer', { sendProgress({
installerPath,
resolvedPath,
logPath
})
sendProgress( {
phase: 'installing', phase: 'installing',
percent: 0, percent: 0,
message: 'Installing update...' message: 'Installing update...'
@ -300,103 +154,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. // Allow file handles from the download/copy to settle before the installer opens.
await sleep(2000) await sleep(2000)
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.focus?.()
mainWindow.show?.()
}
const stopProgressWatch = startWindowsInstallerProgressWatch( const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath, logPath,
sendProgress sendProgress
) )
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let processOutput = '' // Silent NSIS install in a child process (not a batch file).
const startedAt = Date.now() // /S = silent (https://nsis.sourceforge.io/Reference/SilentInstall)
// /UPDATE = in-app update (skip killing this process; overwrite in place)
// /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS)
const installerArgs = ['/S', '/UPDATE', `/LOG=${logPath}`]
const installerArgs = [ const installerProcess = spawn(resolvedPath, installerArgs, {
'/i', detached: false,
resolvedPath, env: {
'/qn', ...process.env,
'/norestart', FARMCONTROL_INSTALL_LOG: logPath
'ALLUSERS=2', },
'MSIINSTALLPERUSER=1',
'REBOOT=ReallySuppress',
'/L*v!',
logPath
]
debugLog('spawning msiexec', {
args: installerArgs,
elapsedMs: Date.now() - startedAt
})
const installerProcess = spawn('msiexec.exe', installerArgs, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true windowsHide: true
}) })
let processOutput = ''
installerProcess.stdout?.on('data', (data) => { installerProcess.stdout?.on('data', (data) => {
const text = data.toString('utf16le') processOutput += data.toString('utf8')
processOutput += text
debugLog('msiexec stdout chunk', {
length: text.length,
preview: text.slice(0, 200)
})
}) })
installerProcess.stderr?.on('data', (data) => { installerProcess.stderr?.on('data', (data) => {
const text = data.toString('utf16le') processOutput += data.toString('utf8')
processOutput += text
debugLog('msiexec stderr chunk', {
length: text.length,
preview: text.slice(0, 200)
})
})
installerProcess.on('spawn', () => {
debugLog('msiexec spawned', {
pid: installerProcess.pid,
elapsedMs: Date.now() - startedAt
})
}) })
installerProcess.on('error', async (error) => { installerProcess.on('error', async (error) => {
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
const watchedOutput = await stopProgressWatch() const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput
debugLog('installer spawn failed', { console.error('[app-update] installer error:', error)
watchedOutputLength: watchedOutput.length, const message = getInstallErrorMessage(error, output)
processOutputLength: processOutput.length sendProgress({
})
const message = error?.message || 'Failed to start update installer.'
sendProgress( {
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
}) })
reject(error) reject(new Error(message))
}) })
installerProcess.on('exit', async (code, signal) => { installerProcess.on('exit', async (code) => {
const watchedOutput = await stopProgressWatch() const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput const output = watchedOutput || processOutput
const finalParse = parseWindowsInstallerProgress(output)
debugLog('msiexec exited', { await fs.unlink(logPath).catch(() => {})
code,
signal,
elapsedMs: Date.now() - startedAt,
watchedOutputLength: watchedOutput.length,
processOutputLength: processOutput.length,
parsed: finalParse.stats,
outputPreview: output.slice(0, 500).replace(/\s+/g, ' ')
})
debugLog('keeping install log', { logPath })
if (code !== 0) { if (code !== 0) {
const message = getInstallErrorMessage(null, output) const message = getInstallErrorMessage(
sendProgress( { new Error(`Installer exited with code ${code}.`),
output
)
sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -405,20 +227,9 @@ export const launchWindowsInstaller = async (
return return
} }
const succeeded = if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) {
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) const message = getInstallErrorMessage(null, output)
sendProgress( { sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -427,15 +238,14 @@ export const launchWindowsInstaller = async (
return return
} }
const { percent, message } = finalParse const { percent, message } = parseWindowsInstallerProgress(output)
sendProgress( { sendProgress({
phase: 'installing', phase: 'installing',
percent: percent ?? 100, percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...' message: message || 'Installation complete. Restarting Farm Control...'
}) })
debugLog('installer completed successfully')
resolve() resolve()
}) })
}) })