Add macOS and Windows installer modules for improved update handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- 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.
This commit is contained in:
Tom Butcher 2026-06-21 20:03:18 +01:00
parent ea7ceea202
commit b93b53fd33
3 changed files with 309 additions and 225 deletions

View File

@ -1,15 +1,12 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { createWriteStream, promises as fs } from 'fs' import { createWriteStream, promises as fs } from 'fs'
import { spawn } from 'child_process'
import { createRequire } from 'module'
import http from 'http' import http from 'http'
import https from 'https' import https from 'https'
import os from 'os' import os from 'os'
import path from 'path' import path from 'path'
import process from 'process' import process from 'process'
import { launchMacInstaller } from './macappupdate.js'
const require = createRequire(import.meta.url) import { launchWindowsInstaller } from './winappupdate.js'
const sudo = require('@vscode/sudo-prompt')
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress' const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
const SUPPORTED_TARGETS = { 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) => const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (redirectCount > 5) { if (redirectCount > 5) {
@ -172,233 +189,20 @@ const restartApp = (app) => {
app.exit(0) 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) => { const launchInstallerAndQuit = async (app, installerPath, webContents) => {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
await launchMacInstaller(app, installerPath, webContents) await launchMacInstaller(app, installerPath, webContents, installerHelpers)
restartApp(app) restartApp(app)
return return
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
await launchWindowsInstaller(app, installerPath, webContents) await launchWindowsInstaller(
app,
installerPath,
webContents,
installerHelpers
)
restartApp(app) restartApp(app)
return return
} }

181
public/macappupdate.js Normal file
View File

@ -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()
})
})
}

99
public/winappupdate.js Normal file
View File

@ -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()
})
}