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 'fs'
import http from 'http'
import https from 'https'
import os from 'os'
import path from 'path'
import process from 'process'
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 { scheduleAppRestart } from './updater-runner.js'
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
const SUPPORTED_TARGETS = {
darwin: {
extension: '.pkg',
osMatchers: ['darwin', 'mac', 'macos', 'osx']
},
win32: {
extension: '.exe',
extension: '.msi',
osMatchers: ['win32', 'win', 'windows']
}
}
@ -87,14 +87,6 @@ const selectUpdateArtifact = (
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()
@ -110,10 +102,16 @@ const getInstallErrorMessage = (error, output = '') => {
return 'The administrator password was incorrect.'
}
return combined || 'Failed to install update.'
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.'
}
const installerHelpers = { sendProgress, getInstallErrorMessage }
return combined || 'Failed to install update.'
}
const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => {
@ -144,7 +142,7 @@ const getDownloadUrl = (url, redirectCount = 0) =>
request.on('error', reject)
})
const downloadArtifact = async (artifact, destinationPath, webContents) => {
const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) {
@ -165,7 +163,7 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => {
? Math.round((downloadedBytes / totalBytes) * 100)
: null
sendProgress(webContents, {
sendProgress({
phase: 'downloading',
percent,
downloadedBytes,
@ -183,33 +181,36 @@ const downloadArtifact = async (artifact, destinationPath, webContents) => {
})
}
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,
const launchInstallerAndRestart = async (
mainWindow,
installerPath,
webContents,
sendProgress
) => {
const installerHelpers = { sendProgress, getInstallErrorMessage }
if (process.platform === 'darwin') {
await launchMacInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers
)
restartApp(app)
return
}
} else if (process.platform === 'win32') {
await launchWindowsInstaller(
mainWindow,
installerPath,
sendProgress,
installerHelpers
)
} else {
throw new Error(`App updates are not supported on ${process.platform}.`)
}
const runAppUpdate = async (app, update, webContents) => {
scheduleAppRestart()
Utils.quit()
}
const runAppUpdate = async (mainWindow, update, sendProgress) => {
const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'farmcontrol-update-')
@ -217,16 +218,16 @@ const runAppUpdate = async (app, update, webContents) => {
const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName)
sendProgress(webContents, {
sendProgress({
phase: 'preparing',
percent: 0,
artifact,
message: 'Preparing update download'
})
await downloadArtifact(artifact, installerPath, webContents)
await downloadArtifact(artifact, installerPath, sendProgress)
sendProgress(webContents, {
sendProgress({
phase: 'downloaded',
percent: 100,
downloadedBytes: null,
@ -235,18 +236,16 @@ const runAppUpdate = async (app, update, webContents) => {
message: 'Update downloaded'
})
await launchInstallerAndQuit(app, installerPath, webContents)
await launchInstallerAndRestart(mainWindow, installerPath, sendProgress)
}
export function setupAppUpdateIPC(app) {
ipcMain.handle('app-update-start', async (event, update) => {
export function startAppUpdate(mainWindow, update, sendProgress) {
if (runningUpdate) return runningUpdate
const webContents = event.sender
runningUpdate = runAppUpdate(app, update, webContents)
runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
.then(() => ({ ok: true }))
.catch((error) => {
sendProgress(webContents, {
sendProgress({
phase: 'error',
percent: null,
message: error?.message || 'Failed to update app.'
@ -258,5 +257,4 @@ export function setupAppUpdateIPC(app) {
})
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 updateDir = path.join(
os.homedir(),
@ -51,16 +51,107 @@ const prepareInstallerPath = async (installerPath) => {
return resolvedPath
}
const readInstallerLog = async (logPath) => {
const startWindowsInstallerProgressWatch = (logPath, sendProgress) => {
let installerOutput = ''
let lastLogSize = 0
let lastPercent = null
let lastMessage = null
let pollCount = 0
const poll = async () => {
pollCount += 1
try {
return await fs.readFile(logPath, 'utf8')
} catch {
return ''
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 (
app,
mainWindow,
installerPath,
webContents,
{ sendProgress, getInstallErrorMessage }
@ -74,7 +165,7 @@ export const launchWindowsInstaller = async (
logPath
})
sendProgress(webContents, {
sendProgress({
phase: 'installing',
percent: 0,
message: 'Installing update...'
@ -84,11 +175,26 @@ export const launchWindowsInstaller = async (
await sleep(2000)
const stopProgressWatch = startWindowsInstallerProgressWatch(
logPath,
sendProgress
)
return new Promise((resolve, reject) => {
let processOutput = ''
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', {
installerPath: resolvedPath,
@ -120,7 +226,7 @@ export const launchWindowsInstaller = async (
console.error(`${DEBUG_PREFIX} installer spawn error:`, error)
const message = error?.message || 'Failed to start update installer.'
sendProgress(webContents, {
sendProgress({
phase: 'error',
percent: null,
message
@ -144,10 +250,8 @@ export const launchWindowsInstaller = async (
debugLog('keeping install log', { logPath })
if (code !== 0) {
const message =
getInstallErrorMessage(null, output) ||
`Update installer failed with exit code ${code ?? 'unknown'}.`
sendProgress(webContents, {
const message = getInstallErrorMessage(null, output)
sendProgress({
phase: 'error',
percent: null,
message
@ -156,7 +260,31 @@ export const launchWindowsInstaller = async (
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',
percent: 100,
message: 'Installation complete. Restarting Farm Control...'

View File

@ -88,7 +88,17 @@ export const launchWindowsInstaller = async (
let processOutput = ''
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', {
installerPath: resolvedPath,

View File

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