Compare commits

..

No commits in common. "b93b53fd339b7c18b5e3d8c9100804e408933c2a" and "1b8f0b144501960321e5befd59fb28478677bb2c" have entirely different histories.

4 changed files with 231 additions and 317 deletions

View File

@ -1,12 +1,15 @@
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'
import { launchWindowsInstaller } from './winappupdate.js' const require = createRequire(import.meta.url)
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 = {
@ -95,26 +98,6 @@ 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) {
@ -189,20 +172,233 @@ 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, installerHelpers) await launchMacInstaller(app, installerPath, webContents)
restartApp(app) restartApp(app)
return return
} }
if (process.platform === 'win32') { if (process.platform === 'win32') {
await launchWindowsInstaller( await launchWindowsInstaller(app, installerPath, webContents)
app,
installerPath,
webContents,
installerHelpers
)
restartApp(app) restartApp(app)
return return
} }

View File

@ -1,181 +0,0 @@
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()
})
})
}

View File

@ -1,99 +0,0 @@
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,17 +86,15 @@ 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 =
resolvedStatus === 'complete' status === 'complete'
? CheckCircleIcon ? CheckCircleIcon
: resolvedStatus === 'error' : status === 'error'
? XMarkCircleIcon ? XMarkCircleIcon
: StageIcon : StageIcon
@ -106,12 +104,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[resolvedStatus]} {config.labels[status]}
</Text> </Text>
{showProgress && ( {showProgress && (
<Progress <Progress
percent={resolvedPercent} percent={resolvedPercent}
status={getProgressStatus(resolvedStatus)} status={getProgressStatus(status)}
showInfo={typeof resolvedPercent === 'number'} showInfo={typeof resolvedPercent === 'number'}
style={{ flex: 1, margin: 0 }} style={{ flex: 1, margin: 0 }}
/> />