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"
InstallDirRegKey HKCU "Software\Tom Butcher\Farm Control" "InstallDir"
RequestExecutionLevel user
SilentInstall normal
!define MUI_ABORTWARNING
@ -45,19 +46,49 @@ BrandingText "Farm Control v${VERSION}-b${BUILD_NUMBER} Installer"
!insertmacro MUI_LANGUAGE "English"
Function .onInit
!insertmacro initProgressLog
${If} ${Silent}
SetAutoClose true
${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
Section "Farm Control" SecMain
SectionIn RO
!insertmacro progressPhase "Copying application files"
!insertmacro progressStatus "Copying application files..."
!insertmacro progressPercent 35
SetOutPath $INSTDIR
SetOverwrite try
File /r "${APP_SOURCE_DIR}\*.*"
!insertmacro progressPercent 75
!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\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" \
"DisplayName" "Farm Control"
@ -73,6 +104,8 @@ Section "Farm Control" SecMain
"NoRepair" 1
WriteUninstaller "$INSTDIR\Uninstall.exe"
!insertmacro progressSuccess
SectionEnd
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..."
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
!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
ClearErrors
ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Farm Control" "UninstallString"
@ -34,10 +157,13 @@
run_uninstall:
IfFileExists $R0 0 done_uninstall
!insertmacro progressPhase "Removing previous version"
!insertmacro progressStatus "Removing previous Farm Control installation..."
DetailPrint "Removing previous Farm Control installation..."
!insertmacro quitFarmControl
ExecWait '$R0 /S' $R1
ExecWait '"$R0" /S' $R1
DetailPrint "Previous installation removed (exit code: $R1)"
!insertmacro progressLog "installer:STATUS:Previous installation removed"
done_uninstall:
!macroend
@ -66,7 +192,18 @@
RMDir "$SMPROGRAMS\Farm Control"
!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
!insertmacro progressPhase "Configuring Farm Control"
!insertmacro progressStatus "Registering farmcontrol URI handler..."
DetailPrint "Register farmcontrol URI Handler"
DeleteRegKey HKCU "Software\Classes\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\command" "" '"$INSTDIR\bin\bun.exe" "$INSTDIR\bin\deeplink.js" "%1"'
!insertmacro progressStatus "Creating shortcuts..."
DetailPrint "Creating shortcuts"
!insertmacro createDesktopShortcut
!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
!macro customUnInstall

View File

@ -15,7 +15,7 @@ const SUPPORTED_TARGETS = {
osMatchers: ["darwin", "mac", "macos", "osx"],
},
win32: {
extension: ".msi",
extension: ".exe",
osMatchers: ["win32", "win", "windows"],
},
};
@ -107,7 +107,7 @@ const getInstallErrorMessage = (error, output = "") => {
/forbidden by system policy/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.";

View File

@ -2,143 +2,53 @@ import { spawn } from 'child_process'
import { promises as fs } from 'fs'
import os from 'os'
import path from 'path'
import process from 'process'
const MSI_OLE_HEADER = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])
const DEBUG_PREFIX = '[app-update][win-progress]'
const PE_MZ_HEADER = Buffer.from([0x4d, 0x5a]) // "MZ"
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 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 (line.startsWith('installer:PHASE:')) {
message = line.slice('installer:PHASE:'.length).trim() || message
} else if (line.startsWith('installer:STATUS:')) {
const status = line.slice('installer:STATUS:'.length).trim()
if (status) message = status
} else if (line.startsWith('installer:%')) {
const value = Number.parseFloat(line.slice('installer:%'.length))
if (Number.isFinite(value)) {
percent = Math.min(100, Math.round(value <= 1 ? value * 100 : value))
}
} else if (
line.startsWith('installer: ') &&
!line.startsWith('installer:PHASE:') &&
!line.startsWith('installer:STATUS:') &&
!line.startsWith('installer:%')
) {
const text = line.slice('installer: '.length).trim()
if (text) message = text
}
}
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)
}
}
return { percent, message }
}
const isWindowsInstallSuccessful = (output) =>
/Installation success or error status:\s*0\b/.test(output) ||
/MainEngineThread is returning 0\b/.test(output)
/installer: The install was successful\./i.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)
/installer: The install failed/i.test(output)
const isValidMsiPackage = async (filePath) => {
const isValidNsisInstaller = async (filePath) => {
const handle = await fs.open(filePath, 'r')
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)
return header.equals(MSI_OLE_HEADER)
return header.equals(PE_MZ_HEADER)
} finally {
await handle.close()
}
@ -158,7 +68,6 @@ export const prepareInstallerPath = async (installerPath) => {
const stablePath = path.join(updateDir, fileName)
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 stats = await fs.stat(resolvedPath)
@ -166,9 +75,9 @@ export const prepareInstallerPath = async (installerPath) => {
throw new Error('Update installer file is missing or empty.')
}
if (!(await isValidMsiPackage(resolvedPath))) {
if (!(await isValidNsisInstaller(resolvedPath))) {
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) => {
let installerOutput = ''
let lastLogSize = 0
let offset = 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 <= offset) 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)
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 {
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
if (error?.code !== 'ENOENT') {
console.error('[app-update] installer log poll error:', error)
}
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
console.error('[app-update] 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
}
}
@ -286,13 +146,7 @@ export const launchWindowsInstaller = async (
const resolvedPath = await prepareInstallerPath(installerPath)
const logPath = path.join(path.dirname(resolvedPath), 'install.log')
debugLog('prepared installer', {
installerPath,
resolvedPath,
logPath
})
sendProgress( {
sendProgress({
phase: 'installing',
percent: 0,
message: 'Installing update...'
@ -300,103 +154,71 @@ export const launchWindowsInstaller = async (
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)
if (mainWindow && !mainWindow.isDestroyed?.()) {
mainWindow.focus?.()
mainWindow.show?.()
}
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => {
let processOutput = ''
const startedAt = Date.now()
// Silent NSIS install in a child process (not a batch file).
// /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 = [
'/i',
resolvedPath,
'/qn',
'/norestart',
'ALLUSERS=2',
'MSIINSTALLPERUSER=1',
'REBOOT=ReallySuppress',
'/L*v!',
logPath
]
debugLog('spawning msiexec', {
args: installerArgs,
elapsedMs: Date.now() - startedAt
})
const installerProcess = spawn('msiexec.exe', installerArgs, {
const installerProcess = spawn(resolvedPath, installerArgs, {
detached: false,
env: {
...process.env,
FARMCONTROL_INSTALL_LOG: logPath
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
let processOutput = ''
installerProcess.stdout?.on('data', (data) => {
const text = data.toString('utf16le')
processOutput += text
debugLog('msiexec stdout chunk', {
length: text.length,
preview: text.slice(0, 200)
})
processOutput += data.toString('utf8')
})
installerProcess.stderr?.on('data', (data) => {
const text = data.toString('utf16le')
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
})
processOutput += data.toString('utf8')
})
installerProcess.on('error', async (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.'
sendProgress( {
const output = watchedOutput || processOutput
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
sendProgress({
phase: 'error',
percent: null,
message
})
reject(error)
reject(new Error(message))
})
installerProcess.on('exit', async (code, signal) => {
installerProcess.on('exit', async (code) => {
const watchedOutput = await stopProgressWatch()
const output = watchedOutput || processOutput
const finalParse = parseWindowsInstallerProgress(output)
debugLog('msiexec exited', {
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 })
await fs.unlink(logPath).catch(() => {})
if (code !== 0) {
const message = getInstallErrorMessage(null, output)
sendProgress( {
const message = getInstallErrorMessage(
new Error(`Installer exited with code ${code}.`),
output
)
sendProgress({
phase: 'error',
percent: null,
message
@ -405,20 +227,9 @@ export const launchWindowsInstaller = async (
return
}
const succeeded =
isWindowsInstallSuccessful(output) ||
(code === 0 && !isWindowsInstallFailed(output))
debugLog('install success evaluation', {
succeeded,
isSuccessful: isWindowsInstallSuccessful(output),
isFailed: isWindowsInstallFailed(output),
exitCode: code
})
if (!succeeded) {
if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) {
const message = getInstallErrorMessage(null, output)
sendProgress( {
sendProgress({
phase: 'error',
percent: null,
message
@ -427,15 +238,14 @@ export const launchWindowsInstaller = async (
return
}
const { percent, message } = finalParse
const { percent, message } = parseWindowsInstallerProgress(output)
sendProgress( {
sendProgress({
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
debugLog('installer completed successfully')
resolve()
})
})