farmcontrol-ui/src/desktop/appupdate.js
Tom Butcher 324a4a1e6f
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Enhance app version retrieval in appupdate.js
- Introduced a new function `formatFullAppVersion` to format the application version and build number, improving version representation.
- Updated `getRunningAppVersion` to utilize the new formatting function, ensuring accurate version information is displayed.
- Refactored the `getRunningAppState` function to call `getRunningAppVersion`, enhancing clarity and maintainability of the code.
2026-08-09 13:45:06 +01:00

421 lines
12 KiB
JavaScript

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
}