Enhance Windows installer with improved logging and installation progress tracking
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
2383d14a80
commit
4fdf3a233d
@ -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 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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.";
|
||||
|
||||
@ -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]}`)
|
||||
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))
|
||||
}
|
||||
|
||||
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')
|
||||
} 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( {
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
})
|
||||
reject(error)
|
||||
})
|
||||
|
||||
installerProcess.on('exit', async (code, signal) => {
|
||||
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, ' ')
|
||||
console.error('[app-update] installer error:', error)
|
||||
const message = getInstallErrorMessage(error, output)
|
||||
sendProgress({
|
||||
phase: 'error',
|
||||
percent: null,
|
||||
message
|
||||
})
|
||||
reject(new Error(message))
|
||||
})
|
||||
|
||||
debugLog('keeping install log', { logPath })
|
||||
installerProcess.on('exit', async (code) => {
|
||||
const watchedOutput = await stopProgressWatch()
|
||||
const output = watchedOutput || processOutput
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user