All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Changed the installer file extension from `.msi` to `.exe` in both `appupdate.js` and `winappupdate.js` to align with the new installation format. - Refactored the validation function to check for Windows executables instead of MSI packages, enhancing compatibility with the updated installer format.
263 lines
7.1 KiB
JavaScript
263 lines
7.1 KiB
JavaScript
import { ipcMain } from 'electron'
|
|
import { createWriteStream, promises as fs } from 'fs'
|
|
import http from 'http'
|
|
import https from 'https'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import process from 'process'
|
|
import { launchMacInstaller } from './macappupdate.js'
|
|
import { launchWindowsInstaller } from './winappupdate.js'
|
|
|
|
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
|
|
const SUPPORTED_TARGETS = {
|
|
darwin: {
|
|
extension: '.pkg',
|
|
osMatchers: ['darwin', 'mac', 'macos', 'osx']
|
|
},
|
|
win32: {
|
|
extension: '.exe',
|
|
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 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 installerHelpers = { sendProgress, getInstallErrorMessage }
|
|
|
|
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) => {
|
|
app.relaunch()
|
|
app.exit(0)
|
|
}
|
|
|
|
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
|
|
if (process.platform === 'darwin') {
|
|
await launchMacInstaller(app, installerPath, webContents, installerHelpers)
|
|
restartApp(app)
|
|
return
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
await launchWindowsInstaller(
|
|
app,
|
|
installerPath,
|
|
webContents,
|
|
installerHelpers
|
|
)
|
|
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
|
|
})
|
|
}
|