import { createWriteStream, promises as fs } from 'node:fs' import http from 'node:http' import https from 'node:https' import os from 'node:os' import path from 'node:path' import process from 'node:process' import { Utils } from 'electrobun/bun' import { launchMacInstaller } from './macappupdate.js' import { launchWindowsInstaller } from './winappupdate.js' import { checkForDuplicateInstallations } from './check-duplicate-installations.js' import { scheduleAppRestart } from './updater-runner.js' import buildInfo from '../buildInfo.json' import { getAppSettings, setAppSettings } from './store.js' const SUPPORTED_TARGETS = { darwin: { extension: '.pkg', osMatchers: ['darwin', 'mac', 'macos', 'osx'] }, win32: { extension: '.exe', osMatchers: ['win32', 'win', 'windows'] } } const DEFAULT_UPDATE_ENGINE = 'native' 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 normalizeEngine = (engine) => { const value = String(engine || '') .trim() .toLowerCase() if (value === 'chromium' || value === 'cef') return 'chromium' if (value === 'native') return 'native' return null } const artifactIsChromium = (artifact) => { const explicit = String( artifact?.engine || artifact?.renderer || '' ).toLowerCase() if (explicit === 'cef' || explicit === 'chromium') return true if (explicit === 'native') return false const name = getArtifactName(artifact).toLowerCase() return /[-_.]cef(?:[-_.]|$)/.test(name) } const artifactMatchesEngine = (artifact, engine) => { const wantsChromium = normalizeEngine(engine) === 'chromium' return artifactIsChromium(artifact) === wantsChromium } 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 engine = normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [] const matchingArtifact = artifacts.find( (artifact) => artifactMatchesPlatform(artifact, target, platform, arch) && artifactMatchesEngine(artifact, engine) ) if (!matchingArtifact) { const engineLabel = engine === 'chromium' ? 'Chromium (cef)' : 'Native' throw new Error( `No ${target.extension} ${engineLabel} update artifact found for ${platform}/${arch}.` ) } return matchingArtifact } 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.' } if ( /1625/.test(combined) || /forbidden by system policy/i.test(combined) || /Non-assigned apps are disabled/i.test(combined) ) { return 'Update installation was blocked by system policy.' } return combined || 'Failed to install update.' } 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, sendProgress) => { 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({ 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 getRunningEngine = (mainWindow) => mainWindow?.renderer === 'cef' ? 'chromium' : 'native' const formatFullAppVersion = (version, buildNumber) => { const normalizedVersion = String(version || '') .trim() .replace(/^v/i, '') if (!normalizedVersion) return null const build = String( buildNumber || process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || '' ).trim() const buildSuffix = !build || build === 'dev' ? 'dev' : build.startsWith('b') ? build : `b${build}` return `v${normalizedVersion}-${buildSuffix}` } const getRunningAppVersion = () => formatFullAppVersion( process.env.ELECTROBUN_VERSION, buildInfo?.buildNumber ) const getRunningAppState = (mainWindow, settings) => ({ version: getRunningAppVersion(), branch: settings?.appUpdateRunningBranch || null, engine: getRunningEngine(mainWindow) }) // Snapshot the running version/branch/engine so the next launch can tell // whether an update actually completed. const persistCurrentAppState = async (mainWindow) => { try { const settings = await getAppSettings() await setAppSettings({ ...settings, current: getRunningAppState(mainWindow, settings) }) } catch (error) { console.warn('[app-update] Failed to persist current app state.', error) } } // Ignore missing values: a field only counts as changed when it was recorded // both before and after the update. const stateValueChanged = (previous, next) => Boolean(previous) && Boolean(next) && previous !== next let completedUpdateResult = null export const checkForCompletedUpdate = async (mainWindow) => { // Cache per process so repeated renderer calls (e.g. remounts) get the same // answer instead of a false negative after `current` has been rewritten. if (completedUpdateResult) return completedUpdateResult try { const settings = await getAppSettings() const previous = settings?.current && typeof settings.current === 'object' ? settings.current : null const current = getRunningAppState(mainWindow, settings) await setAppSettings({ ...settings, current }) const updated = Boolean(previous) && (stateValueChanged(previous.version, current.version) || stateValueChanged(previous.branch, current.branch) || stateValueChanged( normalizeEngine(previous.engine), normalizeEngine(current.engine) )) const duplicates = checkForDuplicateInstallations() completedUpdateResult = { updated, previous, current, duplicates } } catch (error) { console.warn('[app-update] Failed to check for a completed update.', error) completedUpdateResult = { updated: false, previous: null, current: null, duplicates: checkForDuplicateInstallations() } } return completedUpdateResult } const persistInstalledUpdateSettings = async (update) => { try { const settings = await getAppSettings() const engine = normalizeEngine(update?.engine) || normalizeEngine(settings?.appUpdateEngine) || DEFAULT_UPDATE_ENGINE await setAppSettings({ ...settings, appUpdateEngine: engine, ...(update?.branch ? { appUpdateRunningBranch: update.branch } : {}) }) } catch (error) { console.warn( '[app-update] Failed to persist installed update settings.', error ) } } const launchInstallerAndRestart = async ( mainWindow, installerPath, sendProgress, update ) => { const installerHelpers = { sendProgress, getInstallErrorMessage } if (process.platform === 'darwin') { await launchMacInstaller( mainWindow, installerPath, sendProgress, installerHelpers ) } else if (process.platform === 'win32') { await launchWindowsInstaller( mainWindow, installerPath, sendProgress, installerHelpers ) } else { throw new Error(`App updates are not supported on ${process.platform}.`) } await persistInstalledUpdateSettings(update) if (process.platform === 'darwin') { scheduleAppRestart() } // Give the UI a moment to show completion before the app exits. await new Promise((resolve) => setTimeout(resolve, 500)) Utils.quit() } const runAppUpdate = async (mainWindow, update, sendProgress) => { await persistCurrentAppState(mainWindow) 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({ phase: 'preparing', percent: 0, artifact, message: 'Preparing update download' }) await downloadArtifact(artifact, installerPath, sendProgress) sendProgress({ phase: 'downloaded', percent: 100, downloadedBytes: null, totalBytes: null, artifact, message: 'Update downloaded' }) await launchInstallerAndRestart( mainWindow, installerPath, sendProgress, update ) } export function startAppUpdate(mainWindow, update, sendProgress) { if (runningUpdate) return runningUpdate runningUpdate = runAppUpdate(mainWindow, update, sendProgress) .then(() => ({ ok: true })) .catch((error) => { sendProgress({ phase: 'error', percent: null, message: error?.message || 'Failed to update app.' }) throw error }) .finally(() => { runningUpdate = null }) return runningUpdate }