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 '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
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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...'
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user