Refactor macOS and Windows installer handling to improve progress tracking and error reporting. Introduce functions for parsing installer output and update UI to conditionally display progress based on installation phase.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-06-21 17:37:35 +01:00
parent 6fe30f680f
commit 478010ea5b
2 changed files with 126 additions and 100 deletions

View File

@ -166,45 +166,53 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => {
})
}
const getMacAppPath = (app) => {
const executablePath = app.getPath('exe')
const appPathIndex = executablePath.indexOf('.app/')
if (appPathIndex === -1) return null
return executablePath.slice(0, appPathIndex + 4)
const restartApp = (app) => {
console.log('[app-update] restarting app')
app.relaunch()
app.exit(0)
}
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const writeInstallerLog = async (app, { installerPath, stdout, stderr, error }) => {
const downloadsDir = app.getPath('downloads')
const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
const logPath = path.join(
downloadsDir,
`farmcontrol-update-install-${timestamp}.log`
const parseMacInstallerProgress = (output) => {
const lines = String(output || '').split('\n')
let percent = null
let message = 'Installing update...'
for (const line of lines) {
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 sections = [
`Timestamp: ${new Date().toISOString()}`,
`Installer: ${installerPath}`,
`Status: ${error ? 'failed' : 'success'}`,
'',
'--- stdout ---',
stdout || '(empty)',
'',
'--- stderr ---',
stderr || '(empty)'
]
if (error) {
sections.push('', '--- error ---', error.stack || error.message || String(error))
}
} 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
}
}
await fs.writeFile(logPath, `${sections.join('\n')}\n`, 'utf8')
console.log('[app-update] installer log written to:', logPath)
return logPath
return { percent, message }
}
const isMacInstallSuccessful = (output) =>
/installer: The (install|upgrade) was successful\./i.test(output)
const isMacInstallFailed = (output) =>
/installer: The install failed/i.test(output)
const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ''}`.trim()
@ -223,31 +231,23 @@ const getInstallErrorMessage = (error, output = '') => {
return combined || 'Failed to install update.'
}
const buildMacInstallScript = (app, installerPath) => {
const appPath = getMacAppPath(app)
const relaunchCommand = appPath
? `open ${quoteShellArg(appPath)}`
: `open ${quoteShellArg(app.getPath('exe'))}`
return `sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
const buildMacInstallScript = (installerPath) =>
`sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
installerPath
)} -target / && ${relaunchCommand}`
}
)} -target / -verboseR`
const buildWindowsInstallCommand = (app, installerPath) => ({
const buildWindowsInstallCommand = (installerPath) => ({
command: 'cmd.exe',
args: [
'/d',
'/s',
'/c',
`timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart && start "" "${app.getPath(
'exe'
)}"`
`timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart`
]
})
const launchMacInstaller = (app, installerPath, webContents) => {
const installScript = buildMacInstallScript(app, installerPath)
const installScript = buildMacInstallScript(installerPath)
const promptName = 'farmcontrol'
console.log('[app-update] launching macOS installer:', {
@ -258,7 +258,7 @@ const launchMacInstaller = (app, installerPath, webContents) => {
sendProgress(webContents, {
phase: 'installing',
percent: 100,
percent: 0,
message:
'Installing update. Enter your Mac password when prompted, then Farm Control will restart automatically.'
})
@ -273,15 +273,6 @@ const launchMacInstaller = (app, installerPath, webContents) => {
if (stdout) console.log('[app-update] installer stdout:', stdout)
if (stderr) console.error('[app-update] installer stderr:', stderr)
void writeInstallerLog(app, {
installerPath,
stdout,
stderr,
error
}).catch((logError) => {
console.error('[app-update] failed to write installer log:', logError)
})
if (error) {
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
@ -294,6 +285,24 @@ const launchMacInstaller = (app, installerPath, webContents) => {
return
}
if (isMacInstallFailed(output) || !isMacInstallSuccessful(output)) {
const message = getInstallErrorMessage(null, output)
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = parseMacInstallerProgress(output)
sendProgress(webContents, {
phase: 'installing',
percent: percent ?? 100,
message: message || 'Installation complete. Restarting Farm Control...'
})
console.log('[app-update] installer completed successfully')
resolve()
})
@ -301,7 +310,7 @@ const launchMacInstaller = (app, installerPath, webContents) => {
}
const launchWindowsInstaller = (app, installerPath, webContents) => {
const { command, args } = buildWindowsInstallCommand(app, installerPath)
const { command, args } = buildWindowsInstallCommand(installerPath)
console.log('[app-update] launching installer:', {
installerPath,
@ -317,6 +326,7 @@ const launchWindowsInstaller = (app, installerPath, webContents) => {
message: 'Installing update. Farm Control will restart automatically.'
})
return new Promise((resolve, reject) => {
let installerOutput = ''
const installerProcess = spawn(command, args, {
@ -348,6 +358,7 @@ const launchWindowsInstaller = (app, installerPath, webContents) => {
percent: null,
message: error?.message || 'Failed to start update installer.'
})
reject(error)
})
installerProcess.on('exit', (code, signal) => {
@ -358,25 +369,38 @@ const launchWindowsInstaller = (app, installerPath, webContents) => {
})
if (code !== 0) {
const message = getInstallErrorMessage(null, installerOutput)
sendProgress(webContents, {
phase: 'error',
percent: null,
message: getInstallErrorMessage(null, installerOutput)
message
})
reject(new Error(message))
return
}
sendProgress(webContents, {
phase: 'installing',
percent: 100,
message: 'Installation complete. Restarting Farm Control...'
})
resolve()
})
installerProcess.unref()
})
}
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
if (process.platform === 'darwin') {
await launchMacInstaller(app, installerPath, webContents)
restartApp(app)
return
}
if (process.platform === 'win32') {
launchWindowsInstaller(app, installerPath, webContents)
await launchWindowsInstaller(app, installerPath, webContents)
restartApp(app)
return
}

View File

@ -40,6 +40,8 @@ const AppUpdateProgress = ({ progress, update }) => {
const message = progress?.message || 'Preparing update'
const isInstalling = phase === 'installing'
const isError = phase === 'error'
const showProgress =
!isError && (!isInstalling || typeof progress?.percent === 'number')
return (
<Flex vertical gap='middle'>
@ -59,13 +61,13 @@ const AppUpdateProgress = ({ progress, update }) => {
icon={isInstalling ? undefined : <LoadingOutlined />}
message={
isInstalling
? 'The app will close while the installer runs, then reopen automatically.'
? message
: `Downloading ${artifactName}`
}
/>
)}
{!isInstalling && !isError && (
{showProgress && (
<Progress
percent={percent}
status={getProgressStatus(phase)}