Enhance Windows installer update process with improved timing and error handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Increased sleep durations in the `restartFarmControlAfterUpdate` function to ensure smoother application restarts during updates.
- Refactored the `startWindowsInstallerProgressWatch` function to reduce polling interval for better responsiveness.
- Cleaned up code formatting for improved readability and consistency across the `appupdate.js` and `winappupdate.js` files.
This commit is contained in:
Tom Butcher 2026-08-09 10:12:06 +01:00
parent 6bf8648e1f
commit 67511e8b8c
3 changed files with 189 additions and 192 deletions

View File

@ -311,21 +311,22 @@ Function restartFarmControlAfterUpdate
!insertmacro progressSuccess !insertmacro progressSuccess
!insertmacro progressStatus "Waiting for Farm Control to close..." !insertmacro progressStatus "Waiting for Farm Control to close..."
Sleep 1500 Sleep 2000
restart_wait_loop: restart_wait_loop:
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq FarmControl.exe" 2>nul | find /I "FarmControl.exe"' $R0 ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq FarmControl.exe" 2>nul | find /I "FarmControl.exe"' $R0
${If} $R0 == 0 ${If} $R0 == 0
Sleep 1000 Sleep 1500
Goto restart_wait_loop Goto restart_wait_loop
${EndIf} ${EndIf}
ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0 ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0
${If} $R0 == 0 ${If} $R0 == 0
Sleep 1000 Sleep 1500
Goto restart_wait_loop Goto restart_wait_loop
${EndIf} ${EndIf}
${If} $IsInAppUpdate == "1" ${If} $IsInAppUpdate == "1"
Sleep 1000
!insertmacro swapUpdateStaging !insertmacro swapUpdateStaging
${EndIf} ${EndIf}

View File

@ -1,137 +1,136 @@
import { createWriteStream, promises as fs } from "node:fs"; import { createWriteStream, promises as fs } from 'node:fs'
import http from "node:http"; import http from 'node:http'
import https from "node:https"; import https from 'node:https'
import os from "node:os"; import os from 'node:os'
import path from "node:path"; import path from 'node:path'
import process from "node:process"; import process from 'node:process'
import { Utils } from "electrobun/bun"; 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 { checkForDuplicateInstallations } from "./check-duplicate-installations.js"; import { checkForDuplicateInstallations } from './check-duplicate-installations.js'
import { scheduleAppRestart } from "./updater-runner.js"; import { scheduleAppRestart } from './updater-runner.js'
import { getAppSettings, setAppSettings } from "./store.js"; import { getAppSettings, setAppSettings } from './store.js'
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: '.exe',
osMatchers: ["win32", "win", "windows"], osMatchers: ['win32', 'win', 'windows']
}, }
}; }
const DEFAULT_UPDATE_ENGINE = "native"; const DEFAULT_UPDATE_ENGINE = 'native'
let runningUpdate = null; let runningUpdate = null
const getArtifactName = (artifact) => const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || ""); String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
const normalizeArch = (arch) => { const normalizeArch = (arch) => {
if (arch === "x64" || arch === "amd64") return "x64"; if (arch === 'x64' || arch === 'amd64') return 'x64'
if (arch === "arm64" || arch === "aarch64") return "arm64"; if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return arch; return arch
}; }
const normalizeEngine = (engine) => { const normalizeEngine = (engine) => {
const value = String(engine || "") const value = String(engine || '')
.trim() .trim()
.toLowerCase(); .toLowerCase()
if (value === "chromium" || value === "cef") return "chromium"; if (value === 'chromium' || value === 'cef') return 'chromium'
if (value === "native") return "native"; if (value === 'native') return 'native'
return null; return null
}; }
const artifactIsChromium = (artifact) => { const artifactIsChromium = (artifact) => {
const explicit = String( const explicit = String(
artifact?.engine || artifact?.renderer || "", artifact?.engine || artifact?.renderer || ''
).toLowerCase(); ).toLowerCase()
if (explicit === "cef" || explicit === "chromium") return true; if (explicit === 'cef' || explicit === 'chromium') return true
if (explicit === "native") return false; if (explicit === 'native') return false
const name = getArtifactName(artifact).toLowerCase(); const name = getArtifactName(artifact).toLowerCase()
return /[-_.]cef(?:[-_.]|$)/.test(name); return /[-_.]cef(?:[-_.]|$)/.test(name)
}; }
const artifactMatchesEngine = (artifact, engine) => { const artifactMatchesEngine = (artifact, engine) => {
const wantsChromium = normalizeEngine(engine) === "chromium"; const wantsChromium = normalizeEngine(engine) === 'chromium'
return artifactIsChromium(artifact) === wantsChromium; return artifactIsChromium(artifact) === wantsChromium
}; }
const artifactMatchesPlatform = (artifact, target, platform, arch) => { const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase(); const name = getArtifactName(artifact).toLowerCase()
const normalizedArch = normalizeArch(arch); const normalizedArch = normalizeArch(arch)
const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase()); const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
const artifactPlatform = String( const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || "", artifact?.platform || artifact?.os || artifact?.target || ''
).toLowerCase(); ).toLowerCase()
if (!name.endsWith(target.extension)) return false; if (!name.endsWith(target.extension)) return false
if (!artifact?.url) return false; if (!artifact?.url) return false
const matchesArch = const matchesArch =
artifactArch === normalizedArch || artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) || name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) || name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) || name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch); name.includes(normalizedArch)
const matchesOs = const matchesOs =
!artifactPlatform || !artifactPlatform ||
target.osMatchers.includes(artifactPlatform) || target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) || target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === "darwin" && name.includes("mac")) || (platform === 'darwin' && name.includes('mac')) ||
(platform === "win32" && name.includes("win")); (platform === 'win32' && name.includes('win'))
return matchesArch && matchesOs; return matchesArch && matchesOs
}; }
const selectUpdateArtifact = ( const selectUpdateArtifact = (
update, update,
platform = process.platform, platform = process.platform,
arch = process.arch, arch = process.arch
) => { ) => {
const target = SUPPORTED_TARGETS[platform]; const target = SUPPORTED_TARGETS[platform]
if (!target) { if (!target) {
throw new Error(`App updates are not supported on ${platform}.`); throw new Error(`App updates are not supported on ${platform}.`)
} }
const engine = const engine = normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE
normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE; const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [];
const matchingArtifact = artifacts.find( const matchingArtifact = artifacts.find(
(artifact) => (artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch) && artifactMatchesPlatform(artifact, target, platform, arch) &&
artifactMatchesEngine(artifact, engine), artifactMatchesEngine(artifact, engine)
); )
if (!matchingArtifact) { if (!matchingArtifact) {
const engineLabel = engine === "chromium" ? "Chromium (cef)" : "Native"; const engineLabel = engine === 'chromium' ? 'Chromium (cef)' : 'Native'
throw new Error( throw new Error(
`No ${target.extension} ${engineLabel} update artifact found for ${platform}/${arch}.`, `No ${target.extension} ${engineLabel} update artifact found for ${platform}/${arch}.`
); )
} }
return matchingArtifact; return matchingArtifact
}; }
const getInstallErrorMessage = (error, output = "") => { const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ""}`.trim(); const combined = `${output}\n${error?.message || ''}`.trim()
if ( if (
/cancel/i.test(combined) || /cancel/i.test(combined) ||
/did not grant permission/i.test(combined) || /did not grant permission/i.test(combined) ||
/user canceled/i.test(combined) /user canceled/i.test(combined)
) { ) {
return "Update installation was cancelled."; return 'Update installation was cancelled.'
} }
if (/incorrect/i.test(combined)) { if (/incorrect/i.test(combined)) {
return "The administrator password was incorrect."; return 'The administrator password was incorrect.'
} }
if ( if (
@ -139,124 +138,124 @@ const getInstallErrorMessage = (error, output = "") => {
/forbidden by system policy/i.test(combined) || /forbidden by system policy/i.test(combined) ||
/Non-assigned apps are disabled/i.test(combined) /Non-assigned apps are disabled/i.test(combined)
) { ) {
return "Update installation was blocked by system policy."; return 'Update installation was blocked by system policy.'
} }
return combined || "Failed to install update."; return combined || 'Failed to install update.'
}; }
const getDownloadUrl = (url, redirectCount = 0) => const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
if (redirectCount > 5) { if (redirectCount > 5) {
reject(new Error("Too many redirects while downloading update.")); reject(new Error('Too many redirects while downloading update.'))
return; return
} }
const parsedUrl = new URL(url); const parsedUrl = new URL(url)
const client = parsedUrl.protocol === "https:" ? https : http; const client = parsedUrl.protocol === 'https:' ? https : http
const request = client.get(parsedUrl, (response) => { const request = client.get(parsedUrl, (response) => {
const location = response.headers.location; const location = response.headers.location
if (response.statusCode >= 300 && response.statusCode < 400 && location) { if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume(); response.resume()
resolve( resolve(
getDownloadUrl( getDownloadUrl(
new URL(location, parsedUrl).toString(), new URL(location, parsedUrl).toString(),
redirectCount + 1, redirectCount + 1
), )
); )
return; return
} }
resolve({ response, url: parsedUrl.toString() }); resolve({ response, url: parsedUrl.toString() })
}); })
request.on("error", reject); request.on('error', reject)
}); })
const downloadArtifact = async (artifact, destinationPath, sendProgress) => { 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) {
response.resume(); response.resume()
throw new Error(`Update download failed with HTTP ${response.statusCode}.`); throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
} }
const totalBytes = const totalBytes =
Number.parseInt(response.headers["content-length"], 10) || 0; Number.parseInt(response.headers['content-length'], 10) || 0
let downloadedBytes = 0; let downloadedBytes = 0
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath); const output = createWriteStream(destinationPath)
response.on("data", (chunk) => { response.on('data', (chunk) => {
downloadedBytes += chunk.length; downloadedBytes += chunk.length
const percent = totalBytes const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100) ? Math.round((downloadedBytes / totalBytes) * 100)
: null; : null
sendProgress({ sendProgress({
phase: "downloading", phase: 'downloading',
percent, percent,
downloadedBytes, downloadedBytes,
totalBytes, totalBytes,
message: totalBytes message: totalBytes
? `Downloading update (${percent}%)` ? `Downloading update (${percent}%)`
: "Downloading update", : 'Downloading update'
}); })
}); })
response.on("error", reject); response.on('error', reject)
output.on("error", reject); output.on('error', reject)
output.on("finish", resolve); output.on('finish', resolve)
response.pipe(output); response.pipe(output)
}); })
}; }
const getRunningEngine = (mainWindow) => const getRunningEngine = (mainWindow) =>
mainWindow?.renderer === "cef" ? "chromium" : "native"; mainWindow?.renderer === 'cef' ? 'chromium' : 'native'
const getRunningAppState = (mainWindow, settings) => ({ const getRunningAppState = (mainWindow, settings) => ({
version: process.env.ELECTROBUN_VERSION || null, version: process.env.ELECTROBUN_VERSION || null,
branch: settings?.appUpdateRunningBranch || null, branch: settings?.appUpdateRunningBranch || null,
engine: getRunningEngine(mainWindow), engine: getRunningEngine(mainWindow)
}); })
// Snapshot the running version/branch/engine so the next launch can tell // Snapshot the running version/branch/engine so the next launch can tell
// whether an update actually completed. // whether an update actually completed.
const persistCurrentAppState = async (mainWindow) => { const persistCurrentAppState = async (mainWindow) => {
try { try {
const settings = await getAppSettings(); const settings = await getAppSettings()
await setAppSettings({ await setAppSettings({
...settings, ...settings,
current: getRunningAppState(mainWindow, settings), current: getRunningAppState(mainWindow, settings)
}); })
} catch (error) { } catch (error) {
console.warn("[app-update] Failed to persist current app state.", error); console.warn('[app-update] Failed to persist current app state.', error)
} }
}; }
// Ignore missing values: a field only counts as changed when it was recorded // Ignore missing values: a field only counts as changed when it was recorded
// both before and after the update. // both before and after the update.
const stateValueChanged = (previous, next) => const stateValueChanged = (previous, next) =>
Boolean(previous) && Boolean(next) && previous !== next; Boolean(previous) && Boolean(next) && previous !== next
let completedUpdateResult = null; let completedUpdateResult = null
export const checkForCompletedUpdate = async (mainWindow) => { export const checkForCompletedUpdate = async (mainWindow) => {
// Cache per process so repeated renderer calls (e.g. remounts) get the same // Cache per process so repeated renderer calls (e.g. remounts) get the same
// answer instead of a false negative after `current` has been rewritten. // answer instead of a false negative after `current` has been rewritten.
if (completedUpdateResult) return completedUpdateResult; if (completedUpdateResult) return completedUpdateResult
try { try {
const settings = await getAppSettings(); const settings = await getAppSettings()
const previous = const previous =
settings?.current && typeof settings.current === "object" settings?.current && typeof settings.current === 'object'
? settings.current ? settings.current
: null; : null
const current = getRunningAppState(mainWindow, settings); const current = getRunningAppState(mainWindow, settings)
await setAppSettings({ ...settings, current }); await setAppSettings({ ...settings, current })
const updated = const updated =
Boolean(previous) && Boolean(previous) &&
@ -264,137 +263,135 @@ export const checkForCompletedUpdate = async (mainWindow) => {
stateValueChanged(previous.branch, current.branch) || stateValueChanged(previous.branch, current.branch) ||
stateValueChanged( stateValueChanged(
normalizeEngine(previous.engine), normalizeEngine(previous.engine),
normalizeEngine(current.engine), normalizeEngine(current.engine)
)); ))
const duplicates = checkForDuplicateInstallations(); const duplicates = checkForDuplicateInstallations()
completedUpdateResult = { updated, previous, current, duplicates }; completedUpdateResult = { updated, previous, current, duplicates }
} catch (error) { } catch (error) {
console.warn("[app-update] Failed to check for a completed update.", error); console.warn('[app-update] Failed to check for a completed update.', error)
completedUpdateResult = { completedUpdateResult = {
updated: false, updated: false,
previous: null, previous: null,
current: null, current: null,
duplicates: checkForDuplicateInstallations(), duplicates: checkForDuplicateInstallations()
}; }
} }
return completedUpdateResult; return completedUpdateResult
}; }
const persistInstalledUpdateSettings = async (update) => { const persistInstalledUpdateSettings = async (update) => {
try { try {
const settings = await getAppSettings(); const settings = await getAppSettings()
const engine = const engine =
normalizeEngine(update?.engine) || normalizeEngine(update?.engine) ||
normalizeEngine(settings?.appUpdateEngine) || normalizeEngine(settings?.appUpdateEngine) ||
DEFAULT_UPDATE_ENGINE; DEFAULT_UPDATE_ENGINE
await setAppSettings({ await setAppSettings({
...settings, ...settings,
appUpdateEngine: engine, appUpdateEngine: engine,
...(update?.branch ...(update?.branch ? { appUpdateRunningBranch: update.branch } : {})
? { appUpdateRunningBranch: update.branch } })
: {}),
});
} catch (error) { } catch (error) {
console.warn( console.warn(
"[app-update] Failed to persist installed update settings.", '[app-update] Failed to persist installed update settings.',
error, error
); )
} }
}; }
const launchInstallerAndRestart = async ( const launchInstallerAndRestart = async (
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress,
update, update
) => { ) => {
const installerHelpers = { sendProgress, getInstallErrorMessage }; const installerHelpers = { sendProgress, getInstallErrorMessage }
if (process.platform === "darwin") { if (process.platform === 'darwin') {
await launchMacInstaller( await launchMacInstaller(
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress,
installerHelpers, installerHelpers
); )
} else if (process.platform === "win32") { } else if (process.platform === 'win32') {
await launchWindowsInstaller( await launchWindowsInstaller(
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress,
installerHelpers, installerHelpers
); )
} else { } else {
throw new Error(`App updates are not supported on ${process.platform}.`); throw new Error(`App updates are not supported on ${process.platform}.`)
} }
await persistInstalledUpdateSettings(update); await persistInstalledUpdateSettings(update)
if (process.platform === "darwin") { if (process.platform === 'darwin') {
scheduleAppRestart(); scheduleAppRestart()
} }
// Give the UI a moment to show completion before the app exits. // Give the UI a moment to show completion before the app exits.
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 500))
Utils.quit(); Utils.quit()
}; }
const runAppUpdate = async (mainWindow, update, sendProgress) => { const runAppUpdate = async (mainWindow, update, sendProgress) => {
await persistCurrentAppState(mainWindow); await persistCurrentAppState(mainWindow)
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-')
); )
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({ sendProgress({
phase: "preparing", phase: 'preparing',
percent: 0, percent: 0,
artifact, artifact,
message: "Preparing update download", message: 'Preparing update download'
}); })
await downloadArtifact(artifact, installerPath, sendProgress); await downloadArtifact(artifact, installerPath, sendProgress)
sendProgress({ sendProgress({
phase: "downloaded", phase: 'downloaded',
percent: 100, percent: 100,
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
artifact, artifact,
message: "Update downloaded", message: 'Update downloaded'
}); })
await launchInstallerAndRestart( await launchInstallerAndRestart(
mainWindow, mainWindow,
installerPath, installerPath,
sendProgress, sendProgress,
update, update
); )
}; }
export function startAppUpdate(mainWindow, update, sendProgress) { export function startAppUpdate(mainWindow, update, sendProgress) {
if (runningUpdate) return runningUpdate; if (runningUpdate) return runningUpdate
runningUpdate = runAppUpdate(mainWindow, update, sendProgress) runningUpdate = runAppUpdate(mainWindow, update, sendProgress)
.then(() => ({ ok: true })) .then(() => ({ ok: true }))
.catch((error) => { .catch((error) => {
sendProgress({ sendProgress({
phase: "error", phase: 'error',
percent: null, percent: null,
message: error?.message || "Failed to update app.", message: error?.message || 'Failed to update app.'
}); })
throw error; throw error
}) })
.finally(() => { .finally(() => {
runningUpdate = null; runningUpdate = null
}); })
return runningUpdate; return runningUpdate
} }

View File

@ -107,7 +107,8 @@ const startWindowsInstallerProgressWatch = (
offset = stat.size offset = stat.size
installerOutput += buffer.toString('utf8') installerOutput += buffer.toString('utf8')
const { percent, message } = parseWindowsInstallerProgress(installerOutput) const { percent, message } =
parseWindowsInstallerProgress(installerOutput)
const resolvedMessage = message || 'Installing update...' const resolvedMessage = message || 'Installing update...'
if (percent !== lastPercent || resolvedMessage !== lastMessage) { if (percent !== lastPercent || resolvedMessage !== lastMessage) {
@ -142,7 +143,7 @@ const startWindowsInstallerProgressWatch = (
poll().catch((error) => { poll().catch((error) => {
console.error('[app-update] installer log poll error:', error) console.error('[app-update] installer log poll error:', error)
}) })
}, 300) }, 200)
return async () => { return async () => {
clearInterval(intervalId) clearInterval(intervalId)
@ -223,12 +224,7 @@ export const launchWindowsInstaller = async (
// /UPDATE = in-app update (stage into Farm Control.new; swap after exit) // /UPDATE = in-app update (stage into Farm Control.new; swap after exit)
// /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches // /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches
// /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS) // /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS)
const installerArgs = [ const installerArgs = ['/S', '/UPDATE', '/RESTARTFC', `/LOG=${logPath}`]
'/S',
'/UPDATE',
'/RESTARTFC',
`/LOG=${logPath}`
]
const installerProcess = spawn(resolvedPath, installerArgs, { const installerProcess = spawn(resolvedPath, installerArgs, {
detached: true, detached: true,
@ -265,7 +261,10 @@ export const launchWindowsInstaller = async (
return return
} }
if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) { if (
isWindowsInstallFailed(output) ||
!isWindowsInstallSuccessful(output)
) {
settleFailure(getInstallErrorMessage(null, output)) settleFailure(getInstallErrorMessage(null, output))
return return
} }