Merge branch 'electrobun' of https://git.tombutcher.work/tom/farmcontrol-ui into electrobun
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-08-03 22:58:46 +01:00
commit d5b9714630
4 changed files with 217 additions and 81 deletions

View File

@ -1,21 +1,21 @@
import { ipcMain } from 'electron' import { createWriteStream, promises as fs } from 'node:fs'
import { createWriteStream, promises as fs } from 'fs' import http from 'node:http'
import http from 'http' import https from 'node:https'
import https from 'https' import os from 'node:os'
import os from 'os' import path from 'node:path'
import path from 'path' import process from 'node:process'
import process from 'process' import { Utils } from 'electrobun/bun'
import { launchMacInstaller } from './macappupdate.js' import { launchMacInstaller } from './macappupdate.js'
import { launchWindowsInstaller } from './winappupdate.js' import { launchWindowsInstaller } from './winappupdate.js'
import { scheduleAppRestart } from './updater-runner.js'
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
const SUPPORTED_TARGETS = { const SUPPORTED_TARGETS = {
darwin: { darwin: {
extension: '.pkg', extension: '.pkg',
osMatchers: ['darwin', 'mac', 'macos', 'osx'] osMatchers: ['darwin', 'mac', 'macos', 'osx']
}, },
win32: { win32: {
extension: '.exe', extension: '.msi',
osMatchers: ['win32', 'win', 'windows'] osMatchers: ['win32', 'win', 'windows']
} }
} }
@ -87,14 +87,6 @@ const selectUpdateArtifact = (
return matchingArtifact || fallbackArtifact 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 getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ''}`.trim() const combined = `${output}\n${error?.message || ''}`.trim()
@ -110,11 +102,17 @@ const getInstallErrorMessage = (error, output = '') => {
return 'The administrator password was incorrect.' 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 Windows Installer policy.'
}
return combined || 'Failed to install update.' return combined || 'Failed to install update.'
} }
const installerHelpers = { sendProgress, getInstallErrorMessage }
const getDownloadUrl = (url, redirectCount = 0) => const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (redirectCount > 5) { if (redirectCount > 5) {
@ -144,7 +142,7 @@ const getDownloadUrl = (url, redirectCount = 0) =>
request.on('error', reject) request.on('error', reject)
}) })
const downloadArtifact = async (artifact, destinationPath, webContents) => { const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
const { response } = await getDownloadUrl(artifact.url) const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
@ -165,7 +163,7 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => {
? Math.round((downloadedBytes / totalBytes) * 100) ? Math.round((downloadedBytes / totalBytes) * 100)
: null : null
sendProgress(webContents, { sendProgress({
phase: 'downloading', phase: 'downloading',
percent, percent,
downloadedBytes, downloadedBytes,
@ -183,33 +181,36 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => {
}) })
} }
const restartApp = (app) => { const launchInstallerAndRestart = async (
app.relaunch() mainWindow,
app.exit(0) installerPath,
} sendProgress
) => {
const installerHelpers = { sendProgress, getInstallErrorMessage }
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
await launchMacInstaller(app, installerPath, webContents, installerHelpers) await launchMacInstaller(
restartApp(app) mainWindow,
return
}
if (process.platform === 'win32') {
await launchWindowsInstaller(
app,
installerPath, installerPath,
webContents, sendProgress,
installerHelpers installerHelpers
) )
restartApp(app) } else if (process.platform === 'win32') {
return await launchWindowsInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers
)
} else {
throw new Error(`App updates are not supported on ${process.platform}.`)
} }
throw new Error(`App updates are not supported on ${process.platform}.`) scheduleAppRestart()
Utils.quit()
} }
const runAppUpdate = async (app, update, webContents) => { const runAppUpdate = async (mainWindow, update, sendProgress) => {
const artifact = selectUpdateArtifact(update) const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp( const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'farmcontrol-update-') path.join(os.tmpdir(), 'farmcontrol-update-')
@ -217,16 +218,16 @@ const runAppUpdate = async (app, update, webContents) => {
const artifactName = path.basename(getArtifactName(artifact)) const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName) const installerPath = path.join(tempDirectory, artifactName)
sendProgress(webContents, { sendProgress({
phase: 'preparing', phase: 'preparing',
percent: 0, percent: 0,
artifact, artifact,
message: 'Preparing update download' message: 'Preparing update download'
}) })
await downloadArtifact(artifact, installerPath, webContents) await downloadArtifact(artifact, installerPath, sendProgress)
sendProgress(webContents, { sendProgress({
phase: 'downloaded', phase: 'downloaded',
percent: 100, percent: 100,
downloadedBytes: null, downloadedBytes: null,
@ -235,28 +236,25 @@ const runAppUpdate = async (app, update, webContents) => {
message: 'Update downloaded' message: 'Update downloaded'
}) })
await launchInstallerAndQuit(app, installerPath, webContents) await launchInstallerAndRestart(mainWindow, installerPath, sendProgress)
} }
export function setupAppUpdateIPC(app) { export function startAppUpdate(mainWindow, update, sendProgress) {
ipcMain.handle('app-update-start', async (event, update) => { if (runningUpdate) return runningUpdate
if (runningUpdate) return runningUpdate
const webContents = event.sender runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
runningUpdate = runAppUpdate(app, update, webContents) .then(() => ({ ok: true }))
.then(() => ({ ok: true })) .catch((error) => {
.catch((error) => { sendProgress({
sendProgress(webContents, { phase: 'error',
phase: 'error', percent: null,
percent: null, message: error?.message || 'Failed to update app.'
message: error?.message || 'Failed to update app.'
})
throw error
})
.finally(() => {
runningUpdate = null
}) })
throw error
})
.finally(() => {
runningUpdate = null
})
return runningUpdate return runningUpdate
})
} }

View File

@ -21,7 +21,7 @@ const isValidWindowsExecutable = async (filePath) => {
} }
} }
const prepareInstallerPath = async (installerPath) => { export const prepareInstallerPath = async (installerPath) => {
const fileName = path.basename(installerPath) const fileName = path.basename(installerPath)
const updateDir = path.join( const updateDir = path.join(
os.homedir(), os.homedir(),
@ -51,16 +51,107 @@ const prepareInstallerPath = async (installerPath) => {
return resolvedPath return resolvedPath
} }
const readInstallerLog = async (logPath) => { const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
try { let installerOutput = ''
return await fs.readFile(logPath, 'utf8') let lastLogSize = 0
} catch { let lastPercent = null
return '' let lastMessage = null
let pollCount = 0
const poll = async () => {
pollCount += 1
try {
const stat = await fs.stat(logPath)
if (stat.size === 0) {
debugLog(`poll #${pollCount}: log exists but is empty`, { logPath })
return
}
if (stat.size === lastLogSize) {
debugLog(`poll #${pollCount}: no new log data`, {
logPath,
size: stat.size
})
return
}
const buffer = Buffer.alloc(stat.size)
const handle = await fs.open(logPath, 'r')
try {
await handle.read(buffer, 0, stat.size, 0)
} finally {
await handle.close()
}
lastLogSize = stat.size
installerOutput = decodeMsiLogBuffer(buffer)
const { percent, message, stats } =
parseWindowsInstallerProgress(installerOutput)
const resolvedPercent = percent ?? lastPercent ?? 0
const resolvedMessage = message || 'Installing update...'
debugLog(`poll #${pollCount}: parsed installer log`, {
logPath,
size: stat.size,
textLength: installerOutput.length,
preview: installerOutput.slice(0, 240).replace(/\s+/g, ' '),
parsed: stats,
resolvedPercent,
resolvedMessage
})
if (resolvedPercent !== lastPercent || resolvedMessage !== lastMessage) {
debugLog(`poll #${pollCount}: sending progress update`, {
percent: resolvedPercent,
message: resolvedMessage
})
lastPercent = resolvedPercent
lastMessage = resolvedMessage
sendProgress({
phase: 'installing',
percent: resolvedPercent,
message: resolvedMessage
})
} else {
debugLog(`poll #${pollCount}: progress unchanged, skipping UI update`, {
percent: resolvedPercent,
message: resolvedMessage
})
}
} catch (error) {
if (error?.code === 'ENOENT') {
debugLog(`poll #${pollCount}: log file not created yet`, { logPath })
return
}
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
}
}
const intervalId = setInterval(() => {
poll().catch((error) => {
console.error(`${DEBUG_PREFIX} installer log poll error:`, error)
})
}, 300)
return async () => {
clearInterval(intervalId)
await poll()
debugLog('stopped progress watch', {
logPath,
finalSize: lastLogSize,
textLength: installerOutput.length,
pollCount
})
return installerOutput
} }
} }
export const launchWindowsInstaller = async ( export const launchWindowsInstaller = async (
app, mainWindow,
installerPath, installerPath,
webContents, webContents,
{ sendProgress, getInstallErrorMessage } { sendProgress, getInstallErrorMessage }
@ -74,7 +165,7 @@ export const launchWindowsInstaller = async (
logPath logPath
}) })
sendProgress(webContents, { sendProgress({
phase: 'installing', phase: 'installing',
percent: 0, percent: 0,
message: 'Installing update...' message: 'Installing update...'
@ -84,11 +175,26 @@ export const launchWindowsInstaller = async (
await sleep(2000) await sleep(2000)
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let processOutput = '' let processOutput = ''
const startedAt = Date.now() const startedAt = Date.now()
const installerArgs = ['/S', `/LOG=${logPath}`] const installerArgs = [
'/i',
resolvedPath,
'/qn',
'/norestart',
'ALLUSERS=2',
'MSIINSTALLPERUSER=1',
'REBOOT=ReallySuppress',
'/L*v!',
logPath
]
debugLog('spawning NSIS installer', { debugLog('spawning NSIS installer', {
installerPath: resolvedPath, installerPath: resolvedPath,
@ -120,7 +226,7 @@ export const launchWindowsInstaller = async (
console.error(`${DEBUG_PREFIX} installer spawn error:`, error) console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
const message = error?.message || 'Failed to start update installer.' const message = error?.message || 'Failed to start update installer.'
sendProgress(webContents, { sendProgress({
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -144,10 +250,8 @@ export const launchWindowsInstaller = async (
debugLog('keeping install log', { logPath }) debugLog('keeping install log', { logPath })
if (code !== 0) { if (code !== 0) {
const message = const message = getInstallErrorMessage(null, output)
getInstallErrorMessage(null, output) || sendProgress({
`Update installer failed with exit code ${code ?? 'unknown'}.`
sendProgress(webContents, {
phase: 'error', phase: 'error',
percent: null, percent: null,
message message
@ -156,7 +260,31 @@ export const launchWindowsInstaller = async (
return return
} }
sendProgress(webContents, { const succeeded =
isWindowsInstallSuccessful(output) ||
(code === 0 && !isWindowsInstallFailed(output))
debugLog('install success evaluation', {
succeeded,
isSuccessful: isWindowsInstallSuccessful(output),
isFailed: isWindowsInstallFailed(output),
exitCode: code
})
if (!succeeded) {
const message = getInstallErrorMessage(null, output)
sendProgress({
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
const { percent, message } = finalParse
sendProgress({
phase: 'installing', phase: 'installing',
percent: 100, percent: 100,
message: 'Installation complete. Restarting Farm Control...' message: 'Installation complete. Restarting Farm Control...'

View File

@ -88,7 +88,17 @@ export const launchWindowsInstaller = async (
let processOutput = '' let processOutput = ''
const startedAt = Date.now() const startedAt = Date.now()
const installerArgs = ['/S', `/LOG=${logPath}`] const installerArgs = [
'/i',
resolvedPath,
'/qn',
'/norestart',
'ALLUSERS=2',
'MSIINSTALLPERUSER=1',
'REBOOT=ReallySuppress',
'/L*v!',
logPath
]
debugLog('spawning NSIS installer', { debugLog('spawning NSIS installer', {
installerPath: resolvedPath, installerPath: resolvedPath,

View File

@ -153,10 +153,10 @@ export function syncMaximizedWindowFrame(window) {
} }
window.setFrame( window.setFrame(
maximizedFrame.x, workArea.x - 8,
maximizedFrame.y, workArea.y,
maximizedFrame.width, workArea.width + 16,
maximizedFrame.height workArea.height + 8
) )
return true return true
} }