From 478010ea5bbc7ab0ae718fefc423cc9234cda9b0 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 21 Jun 2026 17:37:35 +0100 Subject: [PATCH] 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. --- public/appupdate.js | 220 ++++++++++-------- .../AppUpdates/AppUpdateProgress.jsx | 6 +- 2 files changed, 126 insertions(+), 100 deletions(-) diff --git a/public/appupdate.js b/public/appupdate.js index 1e22551..2d618c8 100644 --- a/public/appupdate.js +++ b/public/appupdate.js @@ -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...' - 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)) + 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) + ) + } + } 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,66 +326,81 @@ const launchWindowsInstaller = (app, installerPath, webContents) => { message: 'Installing update. Farm Control will restart automatically.' }) - let installerOutput = '' + return new Promise((resolve, reject) => { + let installerOutput = '' - const installerProcess = spawn(command, args, { - detached: true, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) - - installerProcess.stdout?.on('data', (data) => { - const text = data.toString() - installerOutput += text - console.log('[app-update] installer stdout:', text) - }) - - installerProcess.stderr?.on('data', (data) => { - const text = data.toString() - installerOutput += text - console.error('[app-update] installer stderr:', text) - }) - - installerProcess.on('spawn', () => { - console.log('[app-update] installer spawned, pid:', installerProcess.pid) - }) - - installerProcess.on('error', (error) => { - console.error('[app-update] installer spawn error:', error) - sendProgress(webContents, { - phase: 'error', - percent: null, - message: error?.message || 'Failed to start update installer.' - }) - }) - - installerProcess.on('exit', (code, signal) => { - console.log('[app-update] installer exited:', { - code, - signal, - output: installerOutput + const installerProcess = spawn(command, args, { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true }) - if (code !== 0) { + installerProcess.stdout?.on('data', (data) => { + const text = data.toString() + installerOutput += text + console.log('[app-update] installer stdout:', text) + }) + + installerProcess.stderr?.on('data', (data) => { + const text = data.toString() + installerOutput += text + console.error('[app-update] installer stderr:', text) + }) + + installerProcess.on('spawn', () => { + console.log('[app-update] installer spawned, pid:', installerProcess.pid) + }) + + installerProcess.on('error', (error) => { + console.error('[app-update] installer spawn error:', error) sendProgress(webContents, { phase: 'error', percent: null, - message: getInstallErrorMessage(null, installerOutput) + message: error?.message || 'Failed to start update installer.' }) - } - }) + reject(error) + }) - installerProcess.unref() + installerProcess.on('exit', (code, signal) => { + console.log('[app-update] installer exited:', { + code, + signal, + output: installerOutput + }) + + if (code !== 0) { + const message = getInstallErrorMessage(null, installerOutput) + sendProgress(webContents, { + phase: 'error', + percent: null, + 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 } diff --git a/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx index 063c48b..c098f7d 100644 --- a/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx +++ b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx @@ -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 ( @@ -59,13 +61,13 @@ const AppUpdateProgress = ({ progress, update }) => { icon={isInstalling ? undefined : } message={ isInstalling - ? 'The app will close while the installer runs, then reopen automatically.' + ? message : `Downloading ${artifactName}` } /> )} - {!isInstalling && !isError && ( + {showProgress && (