From b93b53fd339b7c18b5e3d8c9100804e408933c2a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 21 Jun 2026 20:03:18 +0100 Subject: [PATCH] Add macOS and Windows installer modules for improved update handling - Introduced macappupdate.js for macOS installer management, including progress tracking and error handling. - Added winappupdate.js for Windows installer execution with process monitoring. - Refactored appupdate.js to utilize new installer modules, enhancing code organization and maintainability. - Improved error messaging and progress reporting during installation processes. --- public/appupdate.js | 254 +++++------------------------------------ public/macappupdate.js | 181 +++++++++++++++++++++++++++++ public/winappupdate.js | 99 ++++++++++++++++ 3 files changed, 309 insertions(+), 225 deletions(-) create mode 100644 public/macappupdate.js create mode 100644 public/winappupdate.js diff --git a/public/appupdate.js b/public/appupdate.js index 68e3564..eac09ed 100644 --- a/public/appupdate.js +++ b/public/appupdate.js @@ -1,15 +1,12 @@ import { ipcMain } from 'electron' import { createWriteStream, promises as fs } from 'fs' -import { spawn } from 'child_process' -import { createRequire } from 'module' import http from 'http' import https from 'https' import os from 'os' import path from 'path' import process from 'process' - -const require = createRequire(import.meta.url) -const sudo = require('@vscode/sudo-prompt') +import { launchMacInstaller } from './macappupdate.js' +import { launchWindowsInstaller } from './winappupdate.js' const UPDATE_PROGRESS_CHANNEL = 'app-update-progress' const SUPPORTED_TARGETS = { @@ -98,6 +95,26 @@ const sendProgress = (webContents, payload) => { }) } +const getInstallErrorMessage = (error, output = '') => { + const combined = `${output}\n${error?.message || ''}`.trim() + + if ( + /cancel/i.test(combined) || + /did not grant permission/i.test(combined) || + /user canceled/i.test(combined) + ) { + return 'Update installation was cancelled.' + } + + if (/incorrect/i.test(combined)) { + return 'The administrator password was incorrect.' + } + + return combined || 'Failed to install update.' +} + +const installerHelpers = { sendProgress, getInstallErrorMessage } + const getDownloadUrl = (url, redirectCount = 0) => new Promise((resolve, reject) => { if (redirectCount > 5) { @@ -172,233 +189,20 @@ const restartApp = (app) => { app.exit(0) } -const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'` - -const parseMacInstallerProgress = (output) => { - const lines = String(output || '').split('\n') - let percent = null - let message = 'Installing update...' - - console.log('[app-update] installer output:', output) - - 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 - } - } - - 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() - - if ( - /cancel/i.test(combined) || - /did not grant permission/i.test(combined) || - /user canceled/i.test(combined) - ) { - return 'Update installation was cancelled.' - } - - if (/incorrect/i.test(combined)) { - return 'The administrator password was incorrect.' - } - - return combined || 'Failed to install update.' -} - -const buildMacInstallScript = (installerPath) => - `sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg( - installerPath - )} -target / -verboseR` - -const buildWindowsInstallCommand = (installerPath) => ({ - command: 'cmd.exe', - args: [ - '/d', - '/s', - '/c', - `timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart` - ] -}) - -const launchMacInstaller = (app, installerPath, webContents) => { - const installScript = buildMacInstallScript(installerPath) - const promptName = 'farmcontrol' - - console.log('[app-update] launching macOS installer:', { - installerPath, - installScript, - promptName - }) - - sendProgress(webContents, { - phase: 'installing', - percent: 0, - message: 'Enter your Mac password when prompted.' - }) - - app.focus({ steal: true }) - app.dock?.show() - - return new Promise((resolve, reject) => { - sudo.exec(installScript, { name: promptName }, (error, stdout, stderr) => { - const output = `${stdout || ''}${stderr || ''}` - - if (stdout) console.log('[app-update] installer stdout:', stdout) - if (stderr) console.error('[app-update] installer stderr:', stderr) - - if (error) { - console.error('[app-update] installer error:', error) - const message = getInstallErrorMessage(error, output) - sendProgress(webContents, { - phase: 'error', - percent: null, - message - }) - reject(new Error(message)) - 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() - }) - }) -} - -const launchWindowsInstaller = (app, installerPath, webContents) => { - const { command, args } = buildWindowsInstallCommand(installerPath) - - console.log('[app-update] launching installer:', { - installerPath, - command, - args, - shellCommand: args.join(' '), - platform: process.platform - }) - - sendProgress(webContents, { - phase: 'installing', - percent: 100, - message: 'Installing update. Farm Control will restart automatically.' - }) - - 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.' - }) - reject(error) - }) - - 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) + await launchMacInstaller(app, installerPath, webContents, installerHelpers) restartApp(app) return } if (process.platform === 'win32') { - await launchWindowsInstaller(app, installerPath, webContents) + await launchWindowsInstaller( + app, + installerPath, + webContents, + installerHelpers + ) restartApp(app) return } diff --git a/public/macappupdate.js b/public/macappupdate.js new file mode 100644 index 0000000..1a38d2f --- /dev/null +++ b/public/macappupdate.js @@ -0,0 +1,181 @@ +import { promises as fs } from 'fs' +import { createRequire } from 'module' +import path from 'path' + +const require = createRequire(import.meta.url) +const sudo = require('@vscode/sudo-prompt') + +const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'` + +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)) + } + } 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 + } + } + + 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 buildMacInstallScript = (installerPath, logPath) => + `sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg( + installerPath + )} -target / -verboseR 2>&1 | /usr/bin/tee ${quoteShellArg(logPath)}` + +const startMacInstallerProgressWatch = (logPath, webContents, sendProgress) => { + let installerOutput = '' + let offset = 0 + let lastPercent = null + let lastMessage = null + + const poll = async () => { + try { + const stat = await fs.stat(logPath) + if (stat.size <= offset) return + + const handle = await fs.open(logPath, 'r') + try { + 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 } = parseMacInstallerProgress(installerOutput) + const resolvedMessage = message || 'Installing update...' + + if (percent !== lastPercent || resolvedMessage !== lastMessage) { + lastPercent = percent + lastMessage = resolvedMessage + sendProgress(webContents, { + phase: 'installing', + percent, + message: resolvedMessage + }) + } + } finally { + await handle.close() + } + } catch (error) { + if (error?.code !== 'ENOENT') { + console.error('[app-update] installer log poll error:', error) + } + } + } + + const intervalId = setInterval(() => { + poll().catch((error) => { + console.error('[app-update] installer log poll error:', error) + }) + }, 300) + + return async () => { + clearInterval(intervalId) + await poll() + return installerOutput + } +} + +export const launchMacInstaller = ( + app, + installerPath, + webContents, + { sendProgress, getInstallErrorMessage } +) => { + const logPath = path.join(path.dirname(installerPath), 'install.log') + const installScript = buildMacInstallScript(installerPath, logPath) + const promptName = 'farmcontrol' + + console.log('[app-update] launching macOS installer:', { + installerPath, + installScript, + logPath, + promptName + }) + + sendProgress(webContents, { + phase: 'installing', + percent: 0, + message: 'Enter your Mac password when prompted.' + }) + + app.focus({ steal: true }) + app.dock?.show() + + const stopProgressWatch = startMacInstallerProgressWatch( + logPath, + webContents, + sendProgress + ) + + return new Promise((resolve, reject) => { + sudo.exec(installScript, { name: promptName }, async (error, stdout, stderr) => { + const watchedOutput = await stopProgressWatch() + const output = `${stdout || ''}${stderr || ''}` || watchedOutput + + await fs.unlink(logPath).catch(() => {}) + + if (stdout) console.log('[app-update] installer stdout:', stdout) + if (stderr) console.error('[app-update] installer stderr:', stderr) + + if (error) { + console.error('[app-update] installer error:', error) + const message = getInstallErrorMessage(error, output) + sendProgress(webContents, { + phase: 'error', + percent: null, + message + }) + reject(new Error(message)) + 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() + }) + }) +} diff --git a/public/winappupdate.js b/public/winappupdate.js new file mode 100644 index 0000000..98c7aae --- /dev/null +++ b/public/winappupdate.js @@ -0,0 +1,99 @@ +import { spawn } from 'child_process' +import process from 'process' + +const buildWindowsInstallCommand = (installerPath) => ({ + command: 'cmd.exe', + args: [ + '/d', + '/s', + '/c', + `timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart` + ] +}) + +export const launchWindowsInstaller = ( + app, + installerPath, + webContents, + { sendProgress, getInstallErrorMessage } +) => { + const { command, args } = buildWindowsInstallCommand(installerPath) + + console.log('[app-update] launching installer:', { + installerPath, + command, + args, + shellCommand: args.join(' '), + platform: process.platform + }) + + sendProgress(webContents, { + phase: 'installing', + percent: 100, + message: 'Installing update. Farm Control will restart automatically.' + }) + + 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.' + }) + reject(error) + }) + + 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() + }) +}