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

View File

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