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...' 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 }) }