diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index 14b5255..ed4a844 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -311,21 +311,22 @@ Function restartFarmControlAfterUpdate !insertmacro progressSuccess !insertmacro progressStatus "Waiting for Farm Control to close..." - Sleep 1500 + Sleep 2000 restart_wait_loop: ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq FarmControl.exe" 2>nul | find /I "FarmControl.exe"' $R0 ${If} $R0 == 0 - Sleep 1000 + Sleep 1500 Goto restart_wait_loop ${EndIf} ExecWait 'cmd.exe /c tasklist /FI "IMAGENAME eq launcher.exe" 2>nul | find /I "launcher.exe"' $R0 ${If} $R0 == 0 - Sleep 1000 + Sleep 1500 Goto restart_wait_loop ${EndIf} ${If} $IsInAppUpdate == "1" + Sleep 1000 !insertmacro swapUpdateStaging ${EndIf} diff --git a/src/desktop/appupdate.js b/src/desktop/appupdate.js index 8520d07..6edfd8a 100644 --- a/src/desktop/appupdate.js +++ b/src/desktop/appupdate.js @@ -1,137 +1,136 @@ -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 { checkForDuplicateInstallations } from "./check-duplicate-installations.js"; -import { scheduleAppRestart } from "./updater-runner.js"; -import { getAppSettings, setAppSettings } from "./store.js"; +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 { checkForDuplicateInstallations } from './check-duplicate-installations.js' +import { scheduleAppRestart } from './updater-runner.js' +import { getAppSettings, setAppSettings } from './store.js' const SUPPORTED_TARGETS = { darwin: { - extension: ".pkg", - osMatchers: ["darwin", "mac", "macos", "osx"], + extension: '.pkg', + osMatchers: ['darwin', 'mac', 'macos', 'osx'] }, win32: { - extension: ".exe", - osMatchers: ["win32", "win", "windows"], - }, -}; + extension: '.exe', + osMatchers: ['win32', 'win', 'windows'] + } +} -const DEFAULT_UPDATE_ENGINE = "native"; +const DEFAULT_UPDATE_ENGINE = 'native' -let runningUpdate = null; +let runningUpdate = null const getArtifactName = (artifact) => - String(artifact?.fileName || artifact?.relativePath || artifact?.url || ""); + String(artifact?.fileName || artifact?.relativePath || artifact?.url || '') const normalizeArch = (arch) => { - if (arch === "x64" || arch === "amd64") return "x64"; - if (arch === "arm64" || arch === "aarch64") return "arm64"; - return arch; -}; + if (arch === 'x64' || arch === 'amd64') return 'x64' + if (arch === 'arm64' || arch === 'aarch64') return 'arm64' + return arch +} const normalizeEngine = (engine) => { - const value = String(engine || "") + const value = String(engine || '') .trim() - .toLowerCase(); + .toLowerCase() - if (value === "chromium" || value === "cef") return "chromium"; - if (value === "native") return "native"; - return null; -}; + if (value === 'chromium' || value === 'cef') return 'chromium' + if (value === 'native') return 'native' + return null +} const artifactIsChromium = (artifact) => { const explicit = String( - artifact?.engine || artifact?.renderer || "", - ).toLowerCase(); + artifact?.engine || artifact?.renderer || '' + ).toLowerCase() - if (explicit === "cef" || explicit === "chromium") return true; - if (explicit === "native") return false; + if (explicit === 'cef' || explicit === 'chromium') return true + if (explicit === 'native') return false - const name = getArtifactName(artifact).toLowerCase(); - return /[-_.]cef(?:[-_.]|$)/.test(name); -}; + const name = getArtifactName(artifact).toLowerCase() + return /[-_.]cef(?:[-_.]|$)/.test(name) +} const artifactMatchesEngine = (artifact, engine) => { - const wantsChromium = normalizeEngine(engine) === "chromium"; - return artifactIsChromium(artifact) === wantsChromium; -}; + const wantsChromium = normalizeEngine(engine) === 'chromium' + return artifactIsChromium(artifact) === wantsChromium +} const artifactMatchesPlatform = (artifact, target, platform, arch) => { - const name = getArtifactName(artifact).toLowerCase(); - const normalizedArch = normalizeArch(arch); - const artifactArch = normalizeArch(String(artifact?.arch || "").toLowerCase()); + const name = getArtifactName(artifact).toLowerCase() + const normalizedArch = normalizeArch(arch) + const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase()) const artifactPlatform = String( - artifact?.platform || artifact?.os || artifact?.target || "", - ).toLowerCase(); + artifact?.platform || artifact?.os || artifact?.target || '' + ).toLowerCase() - if (!name.endsWith(target.extension)) return false; - if (!artifact?.url) return false; + if (!name.endsWith(target.extension)) return false + if (!artifact?.url) return false const matchesArch = artifactArch === normalizedArch || name.includes(`-${normalizedArch}`) || name.includes(`_${normalizedArch}`) || name.includes(`.${normalizedArch}.`) || - name.includes(normalizedArch); + name.includes(normalizedArch) const matchesOs = !artifactPlatform || target.osMatchers.includes(artifactPlatform) || target.osMatchers.some((matcher) => name.includes(matcher)) || - (platform === "darwin" && name.includes("mac")) || - (platform === "win32" && name.includes("win")); + (platform === 'darwin' && name.includes('mac')) || + (platform === 'win32' && name.includes('win')) - return matchesArch && matchesOs; -}; + return matchesArch && matchesOs +} const selectUpdateArtifact = ( update, platform = process.platform, - arch = process.arch, + arch = process.arch ) => { - const target = SUPPORTED_TARGETS[platform]; + const target = SUPPORTED_TARGETS[platform] if (!target) { - throw new Error(`App updates are not supported on ${platform}.`); + throw new Error(`App updates are not supported on ${platform}.`) } - const engine = - normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE; - const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []; + const engine = normalizeEngine(update?.engine) || DEFAULT_UPDATE_ENGINE + const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : [] const matchingArtifact = artifacts.find( (artifact) => artifactMatchesPlatform(artifact, target, platform, arch) && - artifactMatchesEngine(artifact, engine), - ); + artifactMatchesEngine(artifact, engine) + ) if (!matchingArtifact) { - const engineLabel = engine === "chromium" ? "Chromium (cef)" : "Native"; + const engineLabel = engine === 'chromium' ? 'Chromium (cef)' : 'Native' 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 combined = `${output}\n${error?.message || ""}`.trim(); +const getInstallErrorMessage = (error, output = '') => { + const combined = `${output}\n${error?.message || ''}`.trim() if ( /cancel/i.test(combined) || /did not grant permission/i.test(combined) || /user canceled/i.test(combined) ) { - return "Update installation was cancelled."; + return 'Update installation was cancelled.' } if (/incorrect/i.test(combined)) { - return "The administrator password was incorrect."; + return 'The administrator password was incorrect.' } if ( @@ -139,124 +138,124 @@ const getInstallErrorMessage = (error, output = "") => { /forbidden by system policy/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) => new Promise((resolve, reject) => { if (redirectCount > 5) { - reject(new Error("Too many redirects while downloading update.")); - return; + reject(new Error('Too many redirects while downloading update.')) + return } - const parsedUrl = new URL(url); - const client = parsedUrl.protocol === "https:" ? https : http; + const parsedUrl = new URL(url) + const client = parsedUrl.protocol === 'https:' ? https : http const request = client.get(parsedUrl, (response) => { - const location = response.headers.location; + const location = response.headers.location if (response.statusCode >= 300 && response.statusCode < 400 && location) { - response.resume(); + response.resume() resolve( getDownloadUrl( new URL(location, parsedUrl).toString(), - redirectCount + 1, - ), - ); - return; + redirectCount + 1 + ) + ) + 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 { response } = await getDownloadUrl(artifact.url); + const { response } = await getDownloadUrl(artifact.url) if (response.statusCode < 200 || response.statusCode >= 300) { - response.resume(); - throw new Error(`Update download failed with HTTP ${response.statusCode}.`); + response.resume() + throw new Error(`Update download failed with HTTP ${response.statusCode}.`) } const totalBytes = - Number.parseInt(response.headers["content-length"], 10) || 0; - let downloadedBytes = 0; + Number.parseInt(response.headers['content-length'], 10) || 0 + let downloadedBytes = 0 await new Promise((resolve, reject) => { - const output = createWriteStream(destinationPath); + const output = createWriteStream(destinationPath) - response.on("data", (chunk) => { - downloadedBytes += chunk.length; + response.on('data', (chunk) => { + downloadedBytes += chunk.length const percent = totalBytes ? Math.round((downloadedBytes / totalBytes) * 100) - : null; + : null sendProgress({ - phase: "downloading", + phase: 'downloading', percent, downloadedBytes, totalBytes, message: totalBytes ? `Downloading update (${percent}%)` - : "Downloading update", - }); - }); + : 'Downloading update' + }) + }) - response.on("error", reject); - output.on("error", reject); - output.on("finish", resolve); - response.pipe(output); - }); -}; + response.on('error', reject) + output.on('error', reject) + output.on('finish', resolve) + response.pipe(output) + }) +} const getRunningEngine = (mainWindow) => - mainWindow?.renderer === "cef" ? "chromium" : "native"; + mainWindow?.renderer === 'cef' ? 'chromium' : 'native' const getRunningAppState = (mainWindow, settings) => ({ version: process.env.ELECTROBUN_VERSION || null, branch: settings?.appUpdateRunningBranch || null, - engine: getRunningEngine(mainWindow), -}); + engine: getRunningEngine(mainWindow) +}) // Snapshot the running version/branch/engine so the next launch can tell // whether an update actually completed. const persistCurrentAppState = async (mainWindow) => { try { - const settings = await getAppSettings(); + const settings = await getAppSettings() await setAppSettings({ ...settings, - current: getRunningAppState(mainWindow, settings), - }); + current: getRunningAppState(mainWindow, settings) + }) } 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 // both before and after the update. 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) => { // Cache per process so repeated renderer calls (e.g. remounts) get the same // answer instead of a false negative after `current` has been rewritten. - if (completedUpdateResult) return completedUpdateResult; + if (completedUpdateResult) return completedUpdateResult try { - const settings = await getAppSettings(); + const settings = await getAppSettings() const previous = - settings?.current && typeof settings.current === "object" + settings?.current && typeof settings.current === 'object' ? settings.current - : null; - const current = getRunningAppState(mainWindow, settings); + : null + const current = getRunningAppState(mainWindow, settings) - await setAppSettings({ ...settings, current }); + await setAppSettings({ ...settings, current }) const updated = Boolean(previous) && @@ -264,137 +263,135 @@ export const checkForCompletedUpdate = async (mainWindow) => { stateValueChanged(previous.branch, current.branch) || stateValueChanged( 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) { - 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 = { updated: false, previous: null, current: null, - duplicates: checkForDuplicateInstallations(), - }; + duplicates: checkForDuplicateInstallations() + } } - return completedUpdateResult; -}; + return completedUpdateResult +} const persistInstalledUpdateSettings = async (update) => { try { - const settings = await getAppSettings(); + const settings = await getAppSettings() const engine = normalizeEngine(update?.engine) || normalizeEngine(settings?.appUpdateEngine) || - DEFAULT_UPDATE_ENGINE; + DEFAULT_UPDATE_ENGINE await setAppSettings({ ...settings, appUpdateEngine: engine, - ...(update?.branch - ? { appUpdateRunningBranch: update.branch } - : {}), - }); + ...(update?.branch ? { appUpdateRunningBranch: update.branch } : {}) + }) } catch (error) { console.warn( - "[app-update] Failed to persist installed update settings.", - error, - ); + '[app-update] Failed to persist installed update settings.', + error + ) } -}; +} const launchInstallerAndRestart = async ( mainWindow, installerPath, sendProgress, - update, + update ) => { - const installerHelpers = { sendProgress, getInstallErrorMessage }; + const installerHelpers = { sendProgress, getInstallErrorMessage } - if (process.platform === "darwin") { + if (process.platform === 'darwin') { await launchMacInstaller( mainWindow, installerPath, sendProgress, - installerHelpers, - ); - } else if (process.platform === "win32") { + installerHelpers + ) + } else if (process.platform === 'win32') { await launchWindowsInstaller( mainWindow, installerPath, sendProgress, - installerHelpers, - ); + installerHelpers + ) } 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") { - scheduleAppRestart(); + if (process.platform === 'darwin') { + scheduleAppRestart() } // Give the UI a moment to show completion before the app exits. - await new Promise((resolve) => setTimeout(resolve, 1000)); - Utils.quit(); -}; + await new Promise((resolve) => setTimeout(resolve, 500)) + Utils.quit() +} 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( - path.join(os.tmpdir(), "farmcontrol-update-"), - ); - const artifactName = path.basename(getArtifactName(artifact)); - const installerPath = path.join(tempDirectory, artifactName); + path.join(os.tmpdir(), 'farmcontrol-update-') + ) + const artifactName = path.basename(getArtifactName(artifact)) + const installerPath = path.join(tempDirectory, artifactName) sendProgress({ - phase: "preparing", + phase: 'preparing', percent: 0, artifact, - message: "Preparing update download", - }); + message: 'Preparing update download' + }) - await downloadArtifact(artifact, installerPath, sendProgress); + await downloadArtifact(artifact, installerPath, sendProgress) sendProgress({ - phase: "downloaded", + phase: 'downloaded', percent: 100, downloadedBytes: null, totalBytes: null, artifact, - message: "Update downloaded", - }); + message: 'Update downloaded' + }) await launchInstallerAndRestart( mainWindow, installerPath, sendProgress, - update, - ); -}; + update + ) +} export function startAppUpdate(mainWindow, update, sendProgress) { - if (runningUpdate) return runningUpdate; + if (runningUpdate) return runningUpdate runningUpdate = runAppUpdate(mainWindow, update, sendProgress) .then(() => ({ ok: true })) .catch((error) => { sendProgress({ - phase: "error", + phase: 'error', percent: null, - message: error?.message || "Failed to update app.", - }); - throw error; + message: error?.message || 'Failed to update app.' + }) + throw error }) .finally(() => { - runningUpdate = null; - }); + runningUpdate = null + }) - return runningUpdate; + return runningUpdate } diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index 6a21d3d..b989d4b 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -107,7 +107,8 @@ const startWindowsInstallerProgressWatch = ( offset = stat.size installerOutput += buffer.toString('utf8') - const { percent, message } = parseWindowsInstallerProgress(installerOutput) + const { percent, message } = + parseWindowsInstallerProgress(installerOutput) const resolvedMessage = message || 'Installing update...' if (percent !== lastPercent || resolvedMessage !== lastMessage) { @@ -142,7 +143,7 @@ const startWindowsInstallerProgressWatch = ( poll().catch((error) => { console.error('[app-update] installer log poll error:', error) }) - }, 300) + }, 200) return async () => { clearInterval(intervalId) @@ -223,12 +224,7 @@ export const launchWindowsInstaller = async ( // /UPDATE = in-app update (stage into Farm Control.new; swap after exit) // /RESTARTFC = installer waits for this process to exit, swaps folders, relaunches // /LOG= + FARMCONTROL_INSTALL_LOG = progress log (installer:% / PHASE / STATUS) - const installerArgs = [ - '/S', - '/UPDATE', - '/RESTARTFC', - `/LOG=${logPath}` - ] + const installerArgs = ['/S', '/UPDATE', '/RESTARTFC', `/LOG=${logPath}`] const installerProcess = spawn(resolvedPath, installerArgs, { detached: true, @@ -265,7 +261,10 @@ export const launchWindowsInstaller = async ( return } - if (isWindowsInstallFailed(output) || !isWindowsInstallSuccessful(output)) { + if ( + isWindowsInstallFailed(output) || + !isWindowsInstallSuccessful(output) + ) { settleFailure(getInstallErrorMessage(null, output)) return }