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
}