Compare commits

..

2 Commits

Author SHA1 Message Date
9ef02304fe Add restart stage to app update process
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new restart stage in the app update progress component, allowing for better handling of application restarts.
- Updated the status management to include restart states, enhancing user feedback during the update process.
- Modified installation completion messages to indicate that a restart will occur after installation.
- Adjusted prop types to accommodate the new restart stage in the UpdateStage component.
2026-08-02 20:04:30 +01:00
30f0ac1559 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.
2026-08-02 20:03:54 +01:00
4 changed files with 209 additions and 17 deletions

View File

@ -7,6 +7,7 @@ import process from "node:process";
import { Utils } from "electrobun/bun";
import { launchMacInstaller } from "./macappupdate.js";
import { launchWindowsInstaller } from "./winappupdate.js";
import { scheduleAppRestart } from "./updater-runner.js";
const SUPPORTED_TARGETS = {
darwin: {
@ -172,11 +173,7 @@ const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
});
};
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) {

View File

@ -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}.`)
}

View File

@ -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'),

View File

@ -124,7 +124,7 @@ export function handleDeepLink(url) {
return false
}
openInternalUrl(path)
sendNavigateToRenderer(path)
return true
}