Add macOS and Windows installer modules for improved update handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
ea7ceea202
commit
b93b53fd33
@ -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
|
||||
}
|
||||
|
||||
181
public/macappupdate.js
Normal file
181
public/macappupdate.js
Normal 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
99
public/winappupdate.js
Normal 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()
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user