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
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
This commit is contained in:
commit
d5b9714630
@ -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,11 +102,17 @@ const getInstallErrorMessage = (error, output = '') => {
|
||||
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.'
|
||||
}
|
||||
|
||||
const installerHelpers = { sendProgress, getInstallErrorMessage }
|
||||
|
||||
const getDownloadUrl = (url, redirectCount = 0) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (redirectCount > 5) {
|
||||
@ -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}.`)
|
||||
}
|
||||
|
||||
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 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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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...'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user