All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
460 lines
13 KiB
JavaScript
460 lines
13 KiB
JavaScript
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')
|
|
|
|
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
|
|
const SUPPORTED_TARGETS = {
|
|
darwin: {
|
|
extension: '.pkg',
|
|
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
|
},
|
|
win32: {
|
|
extension: '.msi',
|
|
osMatchers: ['win32', 'win', 'windows']
|
|
}
|
|
}
|
|
|
|
let runningUpdate = null
|
|
|
|
const getArtifactName = (artifact) =>
|
|
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
|
|
|
|
const normalizeArch = (arch) => {
|
|
if (arch === 'x64' || arch === 'amd64') return 'x64'
|
|
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
|
|
return arch
|
|
}
|
|
|
|
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
|
|
const name = getArtifactName(artifact).toLowerCase()
|
|
const normalizedArch = normalizeArch(arch)
|
|
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
|
|
const artifactPlatform = String(
|
|
artifact?.platform || artifact?.os || artifact?.target || ''
|
|
).toLowerCase()
|
|
|
|
if (!name.endsWith(target.extension)) return false
|
|
if (!artifact?.url) return false
|
|
|
|
const matchesArch =
|
|
artifactArch === normalizedArch ||
|
|
name.includes(`-${normalizedArch}`) ||
|
|
name.includes(`_${normalizedArch}`) ||
|
|
name.includes(`.${normalizedArch}.`) ||
|
|
name.includes(normalizedArch)
|
|
|
|
const matchesOs =
|
|
!artifactPlatform ||
|
|
target.osMatchers.includes(artifactPlatform) ||
|
|
target.osMatchers.some((matcher) => name.includes(matcher)) ||
|
|
(platform === 'darwin' && name.includes('mac')) ||
|
|
(platform === 'win32' && name.includes('win'))
|
|
|
|
return matchesArch && matchesOs
|
|
}
|
|
|
|
const selectUpdateArtifact = (
|
|
update,
|
|
platform = process.platform,
|
|
arch = process.arch
|
|
) => {
|
|
const target = SUPPORTED_TARGETS[platform]
|
|
if (!target) {
|
|
throw new Error(`App updates are not supported on ${platform}.`)
|
|
}
|
|
|
|
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
|
|
const matchingArtifact = artifacts.find((artifact) =>
|
|
artifactMatchesPlatform(artifact, target, platform, arch)
|
|
)
|
|
const fallbackArtifact = artifacts.find((artifact) => {
|
|
const name = getArtifactName(artifact).toLowerCase()
|
|
return artifact?.url && name.endsWith(target.extension)
|
|
})
|
|
|
|
if (!matchingArtifact && !fallbackArtifact) {
|
|
throw new Error(
|
|
`No ${target.extension} update artifact found for ${platform}/${arch}.`
|
|
)
|
|
}
|
|
|
|
return matchingArtifact || fallbackArtifact
|
|
}
|
|
|
|
const sendProgress = (webContents, payload) => {
|
|
if (!webContents || webContents.isDestroyed()) return
|
|
webContents.send(UPDATE_PROGRESS_CHANNEL, {
|
|
timestamp: new Date().toISOString(),
|
|
...payload
|
|
})
|
|
}
|
|
|
|
const getDownloadUrl = (url, redirectCount = 0) =>
|
|
new Promise((resolve, reject) => {
|
|
if (redirectCount > 5) {
|
|
reject(new Error('Too many redirects while downloading update.'))
|
|
return
|
|
}
|
|
|
|
const parsedUrl = new URL(url)
|
|
const client = parsedUrl.protocol === 'https:' ? https : http
|
|
const request = client.get(parsedUrl, (response) => {
|
|
const location = response.headers.location
|
|
|
|
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
|
|
response.resume()
|
|
resolve(
|
|
getDownloadUrl(
|
|
new URL(location, parsedUrl).toString(),
|
|
redirectCount + 1
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
resolve({ response, url: parsedUrl.toString() })
|
|
})
|
|
|
|
request.on('error', reject)
|
|
})
|
|
|
|
const downloadArtifact = async (artifact, destinationPath, webContents) => {
|
|
const { response } = await getDownloadUrl(artifact.url)
|
|
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
response.resume()
|
|
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
|
|
}
|
|
|
|
const totalBytes =
|
|
Number.parseInt(response.headers['content-length'], 10) || 0
|
|
let downloadedBytes = 0
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const output = createWriteStream(destinationPath)
|
|
|
|
response.on('data', (chunk) => {
|
|
downloadedBytes += chunk.length
|
|
const percent = totalBytes
|
|
? Math.round((downloadedBytes / totalBytes) * 100)
|
|
: null
|
|
|
|
sendProgress(webContents, {
|
|
phase: 'downloading',
|
|
percent,
|
|
downloadedBytes,
|
|
totalBytes,
|
|
message: totalBytes
|
|
? `Downloading update (${percent}%)`
|
|
: 'Downloading update'
|
|
})
|
|
})
|
|
|
|
response.on('error', reject)
|
|
output.on('error', reject)
|
|
output.on('finish', resolve)
|
|
response.pipe(output)
|
|
})
|
|
}
|
|
|
|
const restartApp = (app) => {
|
|
console.log('[app-update] restarting app')
|
|
app.relaunch()
|
|
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)
|
|
restartApp(app)
|
|
return
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
await launchWindowsInstaller(app, installerPath, webContents)
|
|
restartApp(app)
|
|
return
|
|
}
|
|
|
|
throw new Error(`App updates are not supported on ${process.platform}.`)
|
|
}
|
|
|
|
const runAppUpdate = async (app, update, webContents) => {
|
|
const artifact = selectUpdateArtifact(update)
|
|
const tempDirectory = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), 'farmcontrol-update-')
|
|
)
|
|
const artifactName = path.basename(getArtifactName(artifact))
|
|
const installerPath = path.join(tempDirectory, artifactName)
|
|
|
|
sendProgress(webContents, {
|
|
phase: 'preparing',
|
|
percent: 0,
|
|
artifact,
|
|
message: 'Preparing update download'
|
|
})
|
|
|
|
await downloadArtifact(artifact, installerPath, webContents)
|
|
|
|
sendProgress(webContents, {
|
|
phase: 'downloaded',
|
|
percent: 100,
|
|
downloadedBytes: null,
|
|
totalBytes: null,
|
|
artifact,
|
|
message: 'Update downloaded'
|
|
})
|
|
|
|
await launchInstallerAndQuit(app, installerPath, webContents)
|
|
}
|
|
|
|
export function setupAppUpdateIPC(app) {
|
|
ipcMain.handle('app-update-start', async (event, update) => {
|
|
if (runningUpdate) return runningUpdate
|
|
|
|
const webContents = event.sender
|
|
runningUpdate = runAppUpdate(app, update, webContents)
|
|
.then(() => ({ ok: true }))
|
|
.catch((error) => {
|
|
sendProgress(webContents, {
|
|
phase: 'error',
|
|
percent: null,
|
|
message: error?.message || 'Failed to update app.'
|
|
})
|
|
throw error
|
|
})
|
|
.finally(() => {
|
|
runningUpdate = null
|
|
})
|
|
|
|
return runningUpdate
|
|
})
|
|
}
|