From 30f0ac15592ad9e71709b746e312fd11a4828ab6 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 2 Aug 2026 20:03:54 +0100 Subject: [PATCH] Refactor app update process and restart handling - Removed the restart stage from the app update progress component, simplifying the update stages to only include download and install. - Updated the app update logic to schedule a restart after installation instead of handling it directly in the installer functions. - Enhanced the messaging for installation completion by removing references to automatic restarts in the progress messages. - Introduced a new updater-runner module to manage application restarts across platforms, improving the update workflow. --- .../AppUpdates/AppUpdateProgress.jsx | 25 +-- src/desktop/appupdate.js | 29 ++- src/desktop/macappupdate.js | 2 +- src/desktop/updater-runner.js | 193 ++++++++++++++++++ src/desktop/winappupdate.js | 6 +- src/desktop/window.js | 2 +- 6 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 src/desktop/updater-runner.js diff --git a/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx index 482c3f8..6f4e62f 100644 --- a/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx +++ b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx @@ -4,7 +4,6 @@ import { Button, Flex, Modal, Progress, Typography, theme, Divider } from 'antd' import CloudIcon from '../../../Icons/CloudIcon' import HostIcon from '../../../Icons/HostIcon' -import ReloadIcon from '../../../Icons/ReloadIcon' import CheckCircleIcon from '../../../Icons/CheckCircleIcon' import XMarkCircleIcon from '../../../Icons/XMarkCircleIcon' @@ -46,15 +45,6 @@ const STAGE_CONFIG = { complete: 'Installed', error: 'Install failed' } - }, - restart: { - icon: ReloadIcon, - labels: { - pending: 'Restart', - active: 'Restarting...', - complete: 'Restarted', - error: 'Restart failed' - } } } @@ -79,8 +69,7 @@ const isInstallComplete = (phase, message) => { return ( normalized.includes('complete') || - normalized.includes('successful') || - normalized.includes('restarting') + normalized.includes('successful') ) } @@ -91,12 +80,6 @@ const getInstallStageStatus = (phase, isError, message) => { return 'pending' } -const getRestartStageStatus = (phase, isError, message) => { - if (isError && isInstallComplete(phase, message)) return 'error' - if (isInstallComplete(phase, message)) return 'active' - return 'pending' -} - const getProgressStatus = (stageStatus) => { if (stageStatus === 'error') return 'exception' if (stageStatus === 'complete') return 'success' @@ -112,7 +95,7 @@ const UpdateStage = ({ stage, status, percent, detail }) => { const resolvedStatus = status !== 'error' && resolvedPercent === 100 ? 'complete' : status const color = getStageColor(resolvedStatus, token) - const showProgress = resolvedStatus === 'active' && stage !== 'restart' + const showProgress = resolvedStatus === 'active' const StatusIcon = resolvedStatus === 'complete' @@ -143,7 +126,7 @@ const UpdateStage = ({ stage, status, percent, detail }) => { } UpdateStage.propTypes = { - stage: PropTypes.oneOf(['download', 'install', 'restart']).isRequired, + stage: PropTypes.oneOf(['download', 'install']).isRequired, status: PropTypes.oneOf(['pending', 'active', 'complete', 'error']) .isRequired, percent: PropTypes.number, @@ -165,7 +148,6 @@ const AppUpdateProgress = ({ progress, update, onClose }) => { const downloadStatus = getDownloadStageStatus(phase, isError) const installStatus = getInstallStageStatus(phase, isError, message) - const restartStatus = getRestartStageStatus(phase, isError, message) const downloadPercent = downloadStatus === 'active' ? (phase === 'preparing' ? 0 : percent) : null @@ -202,7 +184,6 @@ const AppUpdateProgress = ({ progress, update, onClose }) => { percent={installPercent} detail={installDetail} /> - { }); }; -const restartApp = () => { - Utils.quit(); -}; - -const launchInstallerAndQuit = async ( +const launchInstallerAndRestart = async ( mainWindow, installerPath, sendProgress, @@ -184,23 +181,25 @@ const launchInstallerAndQuit = async ( const installerHelpers = { sendProgress, getInstallErrorMessage }; if (process.platform === "darwin") { - await launchMacInstaller(mainWindow, installerPath, sendProgress, installerHelpers); - restartApp(); - return; - } - - if (process.platform === "win32") { + await launchMacInstaller( + mainWindow, + installerPath, + sendProgress, + installerHelpers, + ); + } else if (process.platform === "win32") { await launchWindowsInstaller( mainWindow, installerPath, sendProgress, installerHelpers, ); - restartApp(); - return; + } 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 (mainWindow, update, sendProgress) => { @@ -229,7 +228,7 @@ const runAppUpdate = async (mainWindow, update, sendProgress) => { message: "Update downloaded", }); - await launchInstallerAndQuit(mainWindow, installerPath, sendProgress); + await launchInstallerAndRestart(mainWindow, installerPath, sendProgress); }; export function startAppUpdate(mainWindow, update, sendProgress) { diff --git a/src/desktop/macappupdate.js b/src/desktop/macappupdate.js index b111936..031ff35 100644 --- a/src/desktop/macappupdate.js +++ b/src/desktop/macappupdate.js @@ -158,7 +158,7 @@ export const launchMacInstaller = ( sendProgress( { phase: 'installing', percent: percent ?? 100, - message: message || 'Installation complete. Restarting Farm Control...' + message: message || 'Installation complete.' }) resolve() diff --git a/src/desktop/updater-runner.js b/src/desktop/updater-runner.js new file mode 100644 index 0000000..c320cd0 --- /dev/null +++ b/src/desktop/updater-runner.js @@ -0,0 +1,193 @@ +import { execSync, spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' + +const MAC_APP_NAME = 'Farm Control.app' +const WINDOWS_INSTALL_DIR_KEY = + 'HKLM\\Software\\Tom Butcher\\Farm Control' +const WINDOWS_DEFAULT_LAUNCHER = path.join( + process.env.ProgramFiles || 'C:\\Program Files', + 'Farm Control', + 'bin', + 'launcher.exe' +) + +const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"` + +const findMacAppBundle = (startPath) => { + let current = path.resolve(startPath) + + while (current !== path.dirname(current)) { + if (current.endsWith('.app')) { + return current + } + + const baseName = path.basename(current) + if (baseName === 'Contents') { + const bundlePath = path.dirname(current) + if (bundlePath.endsWith('.app')) { + return bundlePath + } + } + + current = path.dirname(current) + } + + return null +} + +const readWindowsInstallDirFromRegistry = () => { + try { + const output = execSync( + `reg query ${quoteBatchArg(WINDOWS_INSTALL_DIR_KEY)} /v InstallDir`, + { encoding: 'utf8', windowsHide: true } + ) + const match = output.match(/InstallDir\s+REG_\w+\s+(.+)/i) + return match?.[1]?.trim() || null + } catch { + return null + } +} + +export const resolveAppLaunchPath = () => { + if (process.platform === 'darwin') { + const bundleFromExec = findMacAppBundle(process.execPath) + if (bundleFromExec) { + return bundleFromExec + } + + const applicationsPath = path.join('/Applications', MAC_APP_NAME) + if (existsSync(applicationsPath)) { + return applicationsPath + } + + return process.execPath + } + + if (process.platform === 'win32') { + const execBase = path.basename(process.execPath).toLowerCase() + if (execBase === 'launcher.exe') { + return process.execPath + } + + const launcherBesideExec = path.join(path.dirname(process.execPath), 'launcher.exe') + if (existsSync(launcherBesideExec)) { + return launcherBesideExec + } + + const launcherInBin = path.join( + path.dirname(process.execPath), + 'bin', + 'launcher.exe' + ) + if (existsSync(launcherInBin)) { + return launcherInBin + } + + const installDir = readWindowsInstallDirFromRegistry() + if (installDir) { + const launcherFromRegistry = path.join(installDir, 'bin', 'launcher.exe') + if (existsSync(launcherFromRegistry)) { + return launcherFromRegistry + } + } + + if (existsSync(WINDOWS_DEFAULT_LAUNCHER)) { + return WINDOWS_DEFAULT_LAUNCHER + } + + return process.execPath + } + + throw new Error(`App updates are not supported on ${process.platform}.`) +} + +const spawnDetachedMacRestart = ({ appLaunchPath, parentPid }) => { + const child = spawn( + '/bin/sh', + [ + '-c', + `while kill -0 ${parentPid} 2>/dev/null; do sleep 0.5; done; sleep 1; /usr/bin/open ${JSON.stringify(appLaunchPath)}` + ], + { + detached: true, + stdio: 'ignore' + } + ) + child.unref() +} + +const spawnDetachedWindowsRestart = ({ appLaunchPath, parentPid }) => { + const updateDir = path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'FarmControl', + 'Updates' + ) + const scriptPath = path.join(updateDir, `restart-${parentPid}.bat`) + const appLaunchWin = appLaunchPath.replaceAll('/', '\\') + const scriptWin = scriptPath.replaceAll('/', '\\') + + const script = `@echo off +setlocal + +set "PARENT_PID=${parentPid}" +set "APP_LAUNCHER=${appLaunchWin}" + +:waitparent +tasklist /FI "PID eq %PARENT_PID%" 2>NUL | find /I "%PARENT_PID%" >NUL && ( + timeout /t 1 /nobreak >nul + goto waitparent +) + +:waitprocesses +tasklist /FI "IMAGENAME eq launcher.exe" 2>NUL | find /I /N "launcher.exe">NUL && goto waitsleep +tasklist /FI "IMAGENAME eq bun.exe" 2>NUL | find /I /N "bun.exe">NUL && goto waitsleep +tasklist 2>NUL | find /I "bun Helper">NUL && goto waitsleep +goto waitdone +:waitsleep +timeout /t 1 /nobreak >nul +goto waitprocesses +:waitdone + +timeout /t 2 /nobreak >nul +start "" "%APP_LAUNCHER%" + +for /f "tokens=1" %%t in ('schtasks /query /fo list ^| findstr /i "FarmControlRestart_"') do ( + schtasks /delete /tn "%%t" /f >nul 2>&1 +) + +ping -n 2 127.0.0.1 >nul +del "%~f0" +` + + mkdirSync(updateDir, { recursive: true }) + writeFileSync(scriptPath, script, 'utf8') + + const taskName = `FarmControlRestart_${parentPid}_${Date.now()}` + execSync( + `schtasks /create /tn ${quoteBatchArg(taskName)} /tr ${quoteBatchArg(`cmd /c "${scriptWin}"`)} /sc once /st 00:00 /f`, + { stdio: 'ignore', windowsHide: true } + ) + execSync(`schtasks /run /tn ${quoteBatchArg(taskName)}`, { + stdio: 'ignore', + windowsHide: true + }) +} + +export const scheduleAppRestart = ({ parentPid = process.pid } = {}) => { + const appLaunchPath = resolveAppLaunchPath() + + if (process.platform === 'darwin') { + spawnDetachedMacRestart({ appLaunchPath, parentPid }) + return { appLaunchPath } + } + + if (process.platform === 'win32') { + spawnDetachedWindowsRestart({ appLaunchPath, parentPid }) + return { appLaunchPath } + } + + throw new Error(`App updates are not supported on ${process.platform}.`) +} diff --git a/src/desktop/winappupdate.js b/src/desktop/winappupdate.js index cf8ef16..841674f 100644 --- a/src/desktop/winappupdate.js +++ b/src/desktop/winappupdate.js @@ -95,7 +95,7 @@ const parseWindowsInstallerProgress = (output) => { if (/Installation success or error status:\s*0\b/.test(line)) { percent = 100 - message = 'Installation complete. Restarting Farm Control...' + message = 'Installation complete.' matchedLines.push('install-success') } } @@ -144,7 +144,7 @@ const isValidMsiPackage = async (filePath) => { } } -const prepareInstallerPath = async (installerPath) => { +export const prepareInstallerPath = async (installerPath) => { const fileName = path.basename(installerPath) const updateDir = path.join( process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), @@ -427,7 +427,7 @@ export const launchWindowsInstaller = async ( sendProgress( { phase: 'installing', percent: percent ?? 100, - message: message || 'Installation complete. Restarting Farm Control...' + message: message || 'Installation complete.' }) debugLog('installer completed successfully') diff --git a/src/desktop/window.js b/src/desktop/window.js index fed758b..59b5009 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -124,7 +124,7 @@ export function handleDeepLink(url) { return false } - openInternalUrl(path) + sendNavigateToRenderer(path) return true }