farmcontrol-ui/public/appupdate.js
2026-06-21 17:08:09 +01:00

437 lines
12 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 getMacAppPath = (app) => {
const executablePath = app.getPath('exe')
const appPathIndex = executablePath.indexOf('.app/')
if (appPathIndex === -1) return null
return executablePath.slice(0, appPathIndex + 4)
}
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const writeInstallerLog = async (app, { installerPath, stdout, stderr, error }) => {
const downloadsDir = app.getPath('downloads')
const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
const logPath = path.join(
downloadsDir,
`farmcontrol-update-install-${timestamp}.log`
)
const sections = [
`Timestamp: ${new Date().toISOString()}`,
`Installer: ${installerPath}`,
`Status: ${error ? 'failed' : 'success'}`,
'',
'--- stdout ---',
stdout || '(empty)',
'',
'--- stderr ---',
stderr || '(empty)'
]
if (error) {
sections.push('', '--- error ---', error.stack || error.message || String(error))
}
await fs.writeFile(logPath, `${sections.join('\n')}\n`, 'utf8')
console.log('[app-update] installer log written to:', logPath)
return logPath
}
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 = (app, installerPath) => {
const appPath = getMacAppPath(app)
const relaunchCommand = appPath
? `open ${quoteShellArg(appPath)}`
: `open ${quoteShellArg(app.getPath('exe'))}`
return `sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
installerPath
)} -target / && ${relaunchCommand}`
}
const buildWindowsInstallCommand = (app, installerPath) => ({
command: 'cmd.exe',
args: [
'/d',
'/s',
'/c',
`timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart && start "" "${app.getPath(
'exe'
)}"`
]
})
const launchMacInstaller = (app, installerPath, webContents) => {
const installScript = buildMacInstallScript(app, installerPath)
const promptName = 'farmcontrol'
console.log('[app-update] launching macOS installer:', {
installerPath,
installScript,
promptName
})
sendProgress(webContents, {
phase: 'installing',
percent: 100,
message:
'Installing update. Enter your Mac password when prompted, then Farm Control will restart automatically.'
})
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)
void writeInstallerLog(app, {
installerPath,
stdout,
stderr,
error
}).catch((logError) => {
console.error('[app-update] failed to write installer log:', logError)
})
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
}
console.log('[app-update] installer completed successfully')
resolve()
})
})
}
const launchWindowsInstaller = (app, installerPath, webContents) => {
const { command, args } = buildWindowsInstallCommand(app, 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.'
})
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.'
})
})
installerProcess.on('exit', (code, signal) => {
console.log('[app-update] installer exited:', {
code,
signal,
output: installerOutput
})
if (code !== 0) {
sendProgress(webContents, {
phase: 'error',
percent: null,
message: getInstallErrorMessage(null, installerOutput)
})
}
})
installerProcess.unref()
}
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
if (process.platform === 'darwin') {
await launchMacInstaller(app, installerPath, webContents)
return
}
if (process.platform === 'win32') {
launchWindowsInstaller(app, installerPath, webContents)
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
})
}