Add duplicate installation checks and update handling in Electron context
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-08-08 20:04:55 +01:00
parent e46e12f15d
commit c5d8d9cf70
7 changed files with 582 additions and 2 deletions

View File

@ -130,6 +130,10 @@ export const AppUpdateProvider = ({ children }) => {
setAppSettings, setAppSettings,
getAppEngine, getAppEngine,
startAppUpdate, startAppUpdate,
checkAppUpdateResult,
checkDuplicateInstallations,
removeDuplicateInstallations,
onDuplicateInstallationsRemoved,
onAppUpdateProgress, onAppUpdateProgress,
onCheckForUpdatesRequest onCheckForUpdatesRequest
} = useContext(ElectronContext) } = useContext(ElectronContext)
@ -139,6 +143,11 @@ export const AppUpdateProvider = ({ children }) => {
const [updatePromptOpen, setUpdatePromptOpen] = useState(false) const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
const [installingUpdate, setInstallingUpdate] = useState(null) const [installingUpdate, setInstallingUpdate] = useState(null)
const [updateProgress, setUpdateProgress] = useState(null) const [updateProgress, setUpdateProgress] = useState(null)
const [completedUpdate, setCompletedUpdate] = useState(null)
const [duplicateInstall, setDuplicateInstall] = useState(null)
const [duplicatePromptOpen, setDuplicatePromptOpen] = useState(false)
const [removingDuplicate, setRemovingDuplicate] = useState(false)
const [duplicateRemovalResult, setDuplicateRemovalResult] = useState(null)
const runningCheckRef = useRef(null) const runningCheckRef = useRef(null)
const updateCheckDependenciesRef = useRef({}) const updateCheckDependenciesRef = useRef({})
@ -293,6 +302,44 @@ export const AppUpdateProvider = ({ children }) => {
} }
}, [isElectron, showUpdateIfAvailable]) }, [isElectron, showUpdateIfAvailable])
useEffect(() => {
if (!isElectron) return undefined
let cancelled = false
const runStartupChecks = async () => {
try {
const result = await checkAppUpdateResult?.()
if (!cancelled && result?.updated) {
setCompletedUpdate(result)
}
const installations = await checkDuplicateInstallations?.()
if (!cancelled && installations?.duplicatePath) {
setDuplicateInstall(installations)
setDuplicatePromptOpen(true)
}
} catch (error) {
console.warn('[AppUpdateContext] Startup update checks failed:', error)
}
}
void runStartupChecks()
return () => {
cancelled = true
}
}, [isElectron, checkAppUpdateResult, checkDuplicateInstallations])
useEffect(() => {
if (!isElectron || !onDuplicateInstallationsRemoved) return undefined
return onDuplicateInstallationsRemoved((result) => {
setRemovingDuplicate(false)
setDuplicateRemovalResult(result || { ok: false })
})
}, [isElectron, onDuplicateInstallationsRemoved])
useEffect(() => { useEffect(() => {
if (!isElectron || !onAppUpdateProgress) return undefined if (!isElectron || !onAppUpdateProgress) return undefined
@ -358,6 +405,25 @@ export const AppUpdateProvider = ({ children }) => {
} }
} }
const handleRemoveDuplicate = async () => {
setRemovingDuplicate(true)
setDuplicateRemovalResult(null)
const started = await removeDuplicateInstallations?.()
if (!started) {
setRemovingDuplicate(false)
setDuplicateRemovalResult({
ok: false,
error: 'Failed to start removing the duplicate installation.'
})
}
}
const closeDuplicatePrompt = () => {
setDuplicatePromptOpen(false)
setDuplicateRemovalResult(null)
}
const updateModalOpen = Boolean(updatePromptOpen || installingUpdate) const updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
const updateModalBusy = const updateModalBusy =
Boolean(installingUpdate) && updateProgress?.phase !== 'error' Boolean(installingUpdate) && updateProgress?.phase !== 'error'
@ -438,6 +504,100 @@ export const AppUpdateProvider = ({ children }) => {
/> />
)} )}
</Modal> </Modal>
<Modal
title={
<Flex align='center' gap='middle'>
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
Update Installed
</Flex>
}
open={Boolean(completedUpdate)}
style={{ maxWidth: 430 }}
centered
onCancel={() => setCompletedUpdate(null)}
footer={[
<Button
key='ok'
type='primary'
onClick={() => setCompletedUpdate(null)}
>
OK
</Button>
]}
>
<Text>
Farm Control was successfully updated to version{' '}
{completedUpdate?.current?.version || appVersion}
{completedUpdate?.previous?.version &&
completedUpdate.previous.version !== completedUpdate?.current?.version
? ` (previously ${completedUpdate.previous.version})`
: ''}
.
</Text>
</Modal>
<Modal
title='Duplicate Installation Found'
open={Boolean(
duplicatePromptOpen && duplicateInstall && !completedUpdate
)}
style={{ maxWidth: 480 }}
centered
closable={!removingDuplicate}
maskClosable={false}
onCancel={removingDuplicate ? undefined : closeDuplicatePrompt}
footer={
removingDuplicate
? null
: duplicateRemovalResult
? [
<Button
key='close'
type='primary'
onClick={closeDuplicatePrompt}
>
OK
</Button>
]
: [
<Button key='no' onClick={closeDuplicatePrompt}>
No
</Button>,
<Button
key='yes'
type='primary'
onClick={handleRemoveDuplicate}
>
Yes
</Button>
]
}
>
{removingDuplicate ? (
<Space size='middle'>
<LoadingOutlined />
<Text>
Removing the duplicate installation... You may be asked for an
administrator password.
</Text>
</Space>
) : duplicateRemovalResult ? (
<Text>
{duplicateRemovalResult.ok
? 'The duplicate installation was removed.'
: duplicateRemovalResult.error ||
'Failed to remove the duplicate installation.'}
</Text>
) : (
<Space direction='vertical' size='small'>
<Text>
Farm Control is now installed in your user applications folder,
but an older copy is still installed at:
</Text>
<Text code>{duplicateInstall?.duplicatePath}</Text>
<Text>Do you want to remove the duplicate installation?</Text>
</Space>
)}
</Modal>
</AppUpdateContext.Provider> </AppUpdateContext.Provider>
) )
} }

View File

@ -296,6 +296,36 @@ const ElectronProvider = ({ children }) => {
[electronAvailable, useElectrobun] [electronAvailable, useElectrobun]
) )
const checkAppUpdateResult = useCallback(async () => {
if (!electronAvailable || !useElectrobun) return null
return await desktopBridge.checkAppUpdateResult()
}, [electronAvailable, useElectrobun])
const checkDuplicateInstallations = useCallback(async () => {
if (!electronAvailable || !useElectrobun) return null
return await desktopBridge.checkDuplicateInstallations()
}, [electronAvailable, useElectrobun])
const removeDuplicateInstallations = useCallback(async () => {
if (!electronAvailable || !useElectrobun) return false
const result = await desktopBridge.removeDuplicateInstallations()
return result?.ok ?? false
}, [electronAvailable, useElectrobun])
const onDuplicateInstallationsRemoved = useCallback(
(handler) => {
if (
!electronAvailable ||
!useElectrobun ||
typeof handler !== 'function'
) {
return () => {}
}
return desktopBridge.onMessage('duplicateInstallationsRemoved', handler)
},
[electronAvailable, useElectrobun]
)
const onAppUpdateProgress = useCallback( const onAppUpdateProgress = useCallback(
(handler) => { (handler) => {
if (!electronAvailable || typeof handler !== 'function') { if (!electronAvailable || typeof handler !== 'function') {
@ -419,6 +449,10 @@ const ElectronProvider = ({ children }) => {
getAppSettings, getAppSettings,
setAppSettings, setAppSettings,
startAppUpdate, startAppUpdate,
checkAppUpdateResult,
checkDuplicateInstallations,
removeDuplicateInstallations,
onDuplicateInstallationsRemoved,
onAppUpdateProgress, onAppUpdateProgress,
onCheckForUpdatesRequest, onCheckForUpdatesRequest,
getToken, getToken,

View File

@ -212,6 +212,69 @@ const downloadArtifact = async (artifact, destinationPath, sendProgress) => {
}); });
}; };
const getRunningEngine = (mainWindow) =>
mainWindow?.renderer === "cef" ? "chromium" : "native";
const getRunningAppState = (mainWindow, settings) => ({
version: process.env.ELECTROBUN_VERSION || null,
branch: settings?.appUpdateRunningBranch || null,
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();
await setAppSettings({
...settings,
current: getRunningAppState(mainWindow, settings),
});
} catch (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;
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;
try {
const settings = await getAppSettings();
const previous =
settings?.current && typeof settings.current === "object"
? settings.current
: null;
const current = getRunningAppState(mainWindow, settings);
await setAppSettings({ ...settings, current });
const updated =
Boolean(previous) &&
(stateValueChanged(previous.version, current.version) ||
stateValueChanged(previous.branch, current.branch) ||
stateValueChanged(
normalizeEngine(previous.engine),
normalizeEngine(current.engine),
));
completedUpdateResult = { updated, previous, current };
} catch (error) {
console.warn("[app-update] Failed to check for a completed update.", error);
completedUpdateResult = { updated: false, previous: null, current: null };
}
return completedUpdateResult;
};
const persistInstalledUpdateSettings = async (update) => { const persistInstalledUpdateSettings = async (update) => {
try { try {
const settings = await getAppSettings(); const settings = await getAppSettings();
@ -273,6 +336,8 @@ const launchInstallerAndRestart = async (
}; };
const runAppUpdate = async (mainWindow, update, sendProgress) => { const runAppUpdate = async (mainWindow, update, sendProgress) => {
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-"),

View File

@ -0,0 +1,294 @@
import { execSync, spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { unlink, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import sudo from '@vscode/sudo-prompt'
import { findMacAppBundle } from './updater-runner.js'
const MAC_APP_NAME = 'Farm Control.app'
const MAC_SYSTEM_APP_PATH = path.join('/Applications', MAC_APP_NAME)
const MAC_PKG_IDENTIFIER = 'com.tombutcher.farmcontrol'
const WINDOWS_APP_DIR_NAME = 'Farm Control'
const WINDOWS_LAUNCHER_EXE = 'FarmControl.exe'
const WINDOWS_LEGACY_LAUNCHER_EXE = 'launcher.exe'
const WINDOWS_REGISTRY_APP_KEY = 'Software\\Tom Butcher\\Farm Control'
const WINDOWS_REGISTRY_UNINSTALL_KEY =
'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Farm Control'
const UNSUPPORTED_RESULT = {
supported: false,
runningInstall: 'unknown',
runningPath: null,
duplicatePath: null,
}
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const isPathInside = (childPath, parentPath) => {
if (!childPath || !parentPath) return false
const relative = path.relative(parentPath, childPath)
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
)
}
const samePath = (left, right) => {
if (!left || !right) return false
if (process.platform === 'win32') {
return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase()
}
return path.resolve(left) === path.resolve(right)
}
const checkMacInstallations = () => {
const runningBundle = findMacAppBundle(process.execPath)
const userAppPath = path.join(os.homedir(), 'Applications', MAC_APP_NAME)
let runningInstall = 'unknown'
if (runningBundle) {
if (isPathInside(runningBundle, path.join(os.homedir(), 'Applications'))) {
runningInstall = 'user'
} else if (isPathInside(runningBundle, '/Applications')) {
runningInstall = 'system'
}
}
const systemCopyExists =
existsSync(MAC_SYSTEM_APP_PATH) &&
!samePath(runningBundle, MAC_SYSTEM_APP_PATH)
return {
supported: true,
runningInstall,
runningPath: runningBundle,
userPath: userAppPath,
duplicatePath:
runningInstall === 'user' && systemCopyExists
? MAC_SYSTEM_APP_PATH
: null,
}
}
const readWindowsInstallDir = (hive) => {
try {
const output = execSync(
`reg query "${hive}\\${WINDOWS_REGISTRY_APP_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
}
}
const windowsDirLooksLikeInstall = (dir) =>
Boolean(dir) &&
(existsSync(path.join(dir, 'bin', WINDOWS_LAUNCHER_EXE)) ||
existsSync(path.join(dir, 'bin', WINDOWS_LEGACY_LAUNCHER_EXE)) ||
existsSync(path.join(dir, WINDOWS_LAUNCHER_EXE)) ||
existsSync(path.join(dir, 'Uninstall.exe')) ||
existsSync(path.join(dir, `Uninstall ${WINDOWS_APP_DIR_NAME}.exe`)))
const getWindowsUserInstallDir = () =>
readWindowsInstallDir('HKCU') ||
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
WINDOWS_APP_DIR_NAME,
)
const getWindowsSystemInstallCandidates = () => {
const candidates = [
readWindowsInstallDir('HKLM'),
process.env.ProgramFiles &&
path.join(process.env.ProgramFiles, WINDOWS_APP_DIR_NAME),
process.env['ProgramFiles(x86)'] &&
path.join(process.env['ProgramFiles(x86)'], WINDOWS_APP_DIR_NAME),
process.env.ProgramW6432 &&
path.join(process.env.ProgramW6432, WINDOWS_APP_DIR_NAME),
].filter(Boolean)
const unique = []
for (const candidate of candidates) {
if (!unique.some((existing) => samePath(existing, candidate))) {
unique.push(candidate)
}
}
return unique
}
const checkWindowsInstallations = () => {
const runningDir = path.dirname(process.execPath)
const userInstallDir = getWindowsUserInstallDir()
const systemCandidates = getWindowsSystemInstallCandidates()
const localAppData =
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
let runningInstall = 'unknown'
if (
isPathInside(runningDir, userInstallDir) ||
isPathInside(runningDir, localAppData)
) {
runningInstall = 'user'
} else if (
systemCandidates.some((candidate) => isPathInside(runningDir, candidate))
) {
runningInstall = 'system'
}
const duplicatePath =
runningInstall === 'user'
? systemCandidates.find(
(candidate) =>
windowsDirLooksLikeInstall(candidate) &&
!isPathInside(runningDir, candidate),
) || null
: null
return {
supported: true,
runningInstall,
runningPath: runningDir,
userPath: userInstallDir,
duplicatePath,
}
}
export const checkForDuplicateInstallations = () => {
try {
if (process.platform === 'darwin') return checkMacInstallations()
if (process.platform === 'win32') return checkWindowsInstallations()
} catch (error) {
console.warn(
'[duplicate-install] Failed to check for duplicate installations.',
error,
)
}
return UNSUPPORTED_RESULT
}
const removeMacDuplicate = (duplicatePath) =>
new Promise((resolve, reject) => {
// Removing from /Applications typically needs admin rights; also forget
// the pkg receipt so future installer runs start clean.
const script = [
`/bin/rm -rf ${quoteShellArg(duplicatePath)}`,
`/usr/sbin/pkgutil --forget ${quoteShellArg(MAC_PKG_IDENTIFIER)} || true`,
].join(' && ')
sudo.exec(script, { name: 'farmcontrol' }, (error, stdout, stderr) => {
if (error) {
const combined = `${stderr || ''}\n${error?.message || ''}`
const message =
/cancel/i.test(combined) || /did not grant permission/i.test(combined)
? 'Removal was cancelled.'
: 'Failed to remove the duplicate installation.'
reject(new Error(message))
return
}
resolve()
})
})
const psQuote = (value) => `'${String(value).replaceAll("'", "''")}'`
// Intentionally does NOT run the old install's Uninstall.exe: it taskkills
// FarmControl.exe, which would terminate the running app. Instead the old
// files, HKLM registry keys and machine-level shortcuts are removed directly.
const buildWindowsRemovalScript = (duplicatePath) =>
[
`$ErrorActionPreference = 'SilentlyContinue'`,
`$installDir = ${psQuote(duplicatePath)}`,
`if (Test-Path -LiteralPath $installDir) {`,
` Remove-Item -LiteralPath $installDir -Recurse -Force`,
`}`,
`& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_APP_KEY}`)} /f 2>$null | Out-Null`,
`& reg.exe delete ${psQuote(`HKLM\\${WINDOWS_REGISTRY_UNINSTALL_KEY}`)} /f 2>$null | Out-Null`,
`$commonStartMenu = Join-Path $env:ProgramData 'Microsoft\\Windows\\Start Menu\\Programs\\${WINDOWS_APP_DIR_NAME}'`,
`if (Test-Path -LiteralPath $commonStartMenu) {`,
` Remove-Item -LiteralPath $commonStartMenu -Recurse -Force`,
`}`,
`$publicDesktopShortcut = 'C:\\Users\\Public\\Desktop\\${WINDOWS_APP_DIR_NAME}.lnk'`,
`if (Test-Path -LiteralPath $publicDesktopShortcut) {`,
` Remove-Item -LiteralPath $publicDesktopShortcut -Force`,
`}`,
`if (Test-Path -LiteralPath $installDir) { exit 1 }`,
`exit 0`,
].join('\r\n')
const removeWindowsDuplicate = async (duplicatePath) => {
const scriptPath = path.join(
os.tmpdir(),
`farmcontrol-remove-duplicate-${Date.now()}.ps1`,
)
await writeFile(scriptPath, buildWindowsRemovalScript(duplicatePath), 'utf8')
// Pre-quote the -File argument: PowerShell 5.1's Start-Process does not
// quote ArgumentList entries containing spaces.
const elevateCommand = [
`$p = Start-Process -FilePath 'powershell.exe'`,
`-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','"${scriptPath.replaceAll("'", "''")}"')`,
`-Verb RunAs -Wait -PassThru; exit $p.ExitCode`,
].join(' ')
try {
await new Promise((resolve, reject) => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', elevateCommand],
{ stdio: 'ignore', windowsHide: true },
)
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) {
resolve()
return
}
// Start-Process throws when the UAC prompt is declined, which exits
// the outer PowerShell with a non-zero code and no script output.
reject(
new Error(
code === 1
? 'Failed to remove the duplicate installation.'
: 'Removal was cancelled.',
),
)
})
})
} finally {
await unlink(scriptPath).catch(() => {})
}
}
export const removeDuplicateInstallations = async () => {
// Re-detect instead of trusting a path from the renderer so this can never
// be used to delete an arbitrary directory.
const check = checkForDuplicateInstallations()
if (!check.duplicatePath) {
return { ok: false, error: 'No duplicate installation was found.' }
}
try {
if (process.platform === 'darwin') {
await removeMacDuplicate(check.duplicatePath)
} else if (process.platform === 'win32') {
await removeWindowsDuplicate(check.duplicatePath)
} else {
return { ok: false, error: 'Not supported on this platform.' }
}
} catch (error) {
return {
ok: false,
error: error?.message || 'Failed to remove the duplicate installation.',
}
}
return { ok: true, removedPath: check.duplicatePath }
}

View File

@ -1,5 +1,9 @@
import { BrowserView } from 'electrobun/bun' import { BrowserView } from 'electrobun/bun'
import { startAppUpdate } from './appupdate.js' import { checkForCompletedUpdate, startAppUpdate } from './appupdate.js'
import {
checkForDuplicateInstallations,
removeDuplicateInstallations
} from './check-duplicate-installations.js'
import { setSidebarViewMenu } from './menu.js' import { setSidebarViewMenu } from './menu.js'
import { sendToRenderer } from './notify.js' import { sendToRenderer } from './notify.js'
import { resizeSpotlightWindow } from './spotlight.js' import { resizeSpotlightWindow } from './spotlight.js'
@ -72,6 +76,24 @@ export function createAppRpc() {
setSidebarViewMenu: async ({ sections }) => ({ setSidebarViewMenu: async ({ sections }) => ({
ok: setSidebarViewMenu(sections) ok: setSidebarViewMenu(sections)
}), }),
checkAppUpdateResult: async () =>
checkForCompletedUpdate(getMainWindow()),
checkDuplicateInstallations: async () =>
checkForDuplicateInstallations(),
removeDuplicateInstallations: async () => {
// Removal waits on an admin/UAC prompt which can outlive the RPC
// timeout; report the outcome via a push message instead.
void removeDuplicateInstallations()
.catch((error) => ({
ok: false,
error:
error?.message || 'Failed to remove the duplicate installation.'
}))
.then((result) => {
sendToRenderer('duplicateInstallationsRemoved', result)
})
return { ok: true }
},
getAppVersion: async () => process.env.ELECTROBUN_VERSION || 'desktop', getAppVersion: async () => process.env.ELECTROBUN_VERSION || 'desktop',
getAppEngine: async () => { getAppEngine: async () => {
const mainWindow = getMainWindow() const mainWindow = getMainWindow()

View File

@ -23,7 +23,7 @@ const resolveExistingLauncher = (...candidates) =>
const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"` const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"`
const findMacAppBundle = (startPath) => { export const findMacAppBundle = (startPath) => {
let current = path.resolve(startPath) let current = path.resolve(startPath)
while (current !== path.dirname(current)) { while (current !== path.dirname(current)) {

View File

@ -181,6 +181,11 @@ const electronAPI = {
getAppSettings: () => invokeRequest('getAppSettings'), getAppSettings: () => invokeRequest('getAppSettings'),
setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }), setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }),
startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }), startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }),
checkAppUpdateResult: () => invokeRequest('checkAppUpdateResult'),
checkDuplicateInstallations: () =>
invokeRequest('checkDuplicateInstallations'),
removeDuplicateInstallations: () =>
invokeRequest('removeDuplicateInstallations'),
resizeSpotlightWindow: (height) => resizeSpotlightWindow: (height) =>
invokeRequest('resizeSpotlightWindow', { height }), invokeRequest('resizeSpotlightWindow', { height }),
setSidebarViewMenu: (sections) => setSidebarViewMenu: (sections) =>