Compare commits

...

2 Commits

Author SHA1 Message Date
b93b53fd33 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.
2026-06-21 20:03:18 +01:00
ea7ceea202 Refactor UpdateStage in AppUpdateProgress to improve status resolution and progress display logic, ensuring accurate representation of update stages. 2026-06-21 19:56:45 +01:00
4 changed files with 317 additions and 231 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()
})
}

View File

@ -86,15 +86,17 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
const { token } = theme.useToken() const { token } = theme.useToken()
const config = STAGE_CONFIG[stage] const config = STAGE_CONFIG[stage]
const StageIcon = config.icon const StageIcon = config.icon
const color = getStageColor(status, token)
const showProgress = status === 'active'
const resolvedPercent = const resolvedPercent =
typeof percent === 'number' ? Math.min(percent, 100) : undefined typeof percent === 'number' ? Math.min(percent, 100) : undefined
const resolvedStatus =
status !== 'error' && resolvedPercent === 100 ? 'complete' : status
const color = getStageColor(resolvedStatus, token)
const showProgress = resolvedStatus === 'active'
const StatusIcon = const StatusIcon =
status === 'complete' resolvedStatus === 'complete'
? CheckCircleIcon ? CheckCircleIcon
: status === 'error' : resolvedStatus === 'error'
? XMarkCircleIcon ? XMarkCircleIcon
: StageIcon : StageIcon
@ -104,12 +106,12 @@ const UpdateStage = ({ stage, status, percent, detail }) => {
<StatusIcon style={{ fontSize: 20, color, flexShrink: 0 }} /> <StatusIcon style={{ fontSize: 20, color, flexShrink: 0 }} />
<Flex align='center' gap='small' style={{ flex: 1, minWidth: 0 }}> <Flex align='center' gap='small' style={{ flex: 1, minWidth: 0 }}>
<Text style={{ flexShrink: 0, minWidth: 96 }}> <Text style={{ flexShrink: 0, minWidth: 96 }}>
{config.labels[status]} {config.labels[resolvedStatus]}
</Text> </Text>
{showProgress && ( {showProgress && (
<Progress <Progress
percent={resolvedPercent} percent={resolvedPercent}
status={getProgressStatus(status)} status={getProgressStatus(resolvedStatus)}
showInfo={typeof resolvedPercent === 'number'} showInfo={typeof resolvedPercent === 'number'}
style={{ flex: 1, margin: 0 }} style={{ flex: 1, margin: 0 }}
/> />