From c5d8d9cf70d97b7b70c8fada1f82824fe6342d79 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 8 Aug 2026 20:04:55 +0100 Subject: [PATCH] Add duplicate installation checks and update handling in Electron context --- .../Dashboard/context/AppUpdateContext.jsx | 160 ++++++++++ .../Dashboard/context/ElectronContext.jsx | 34 ++ src/desktop/appupdate.js | 65 ++++ src/desktop/check-duplicate-installations.js | 294 ++++++++++++++++++ src/desktop/rpc.js | 24 +- src/desktop/updater-runner.js | 2 +- src/electrobun-bridge.js | 5 + 7 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 src/desktop/check-duplicate-installations.js diff --git a/src/components/Dashboard/context/AppUpdateContext.jsx b/src/components/Dashboard/context/AppUpdateContext.jsx index 7589525..da220a8 100644 --- a/src/components/Dashboard/context/AppUpdateContext.jsx +++ b/src/components/Dashboard/context/AppUpdateContext.jsx @@ -130,6 +130,10 @@ export const AppUpdateProvider = ({ children }) => { setAppSettings, getAppEngine, startAppUpdate, + checkAppUpdateResult, + checkDuplicateInstallations, + removeDuplicateInstallations, + onDuplicateInstallationsRemoved, onAppUpdateProgress, onCheckForUpdatesRequest } = useContext(ElectronContext) @@ -139,6 +143,11 @@ export const AppUpdateProvider = ({ children }) => { const [updatePromptOpen, setUpdatePromptOpen] = useState(false) const [installingUpdate, setInstallingUpdate] = 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 updateCheckDependenciesRef = useRef({}) @@ -293,6 +302,44 @@ export const AppUpdateProvider = ({ children }) => { } }, [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(() => { 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 updateModalBusy = Boolean(installingUpdate) && updateProgress?.phase !== 'error' @@ -438,6 +504,100 @@ export const AppUpdateProvider = ({ children }) => { /> )} + + + Update Installed + + } + open={Boolean(completedUpdate)} + style={{ maxWidth: 430 }} + centered + onCancel={() => setCompletedUpdate(null)} + footer={[ + + ]} + > + + Farm Control was successfully updated to version{' '} + {completedUpdate?.current?.version || appVersion} + {completedUpdate?.previous?.version && + completedUpdate.previous.version !== completedUpdate?.current?.version + ? ` (previously ${completedUpdate.previous.version})` + : ''} + . + + + + OK + + ] + : [ + , + + ] + } + > + {removingDuplicate ? ( + + + + Removing the duplicate installation... You may be asked for an + administrator password. + + + ) : duplicateRemovalResult ? ( + + {duplicateRemovalResult.ok + ? 'The duplicate installation was removed.' + : duplicateRemovalResult.error || + 'Failed to remove the duplicate installation.'} + + ) : ( + + + Farm Control is now installed in your user applications folder, + but an older copy is still installed at: + + {duplicateInstall?.duplicatePath} + Do you want to remove the duplicate installation? + + )} + ) } diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx index 0e3b6eb..8f444a2 100644 --- a/src/components/Dashboard/context/ElectronContext.jsx +++ b/src/components/Dashboard/context/ElectronContext.jsx @@ -296,6 +296,36 @@ const ElectronProvider = ({ children }) => { [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( (handler) => { if (!electronAvailable || typeof handler !== 'function') { @@ -419,6 +449,10 @@ const ElectronProvider = ({ children }) => { getAppSettings, setAppSettings, startAppUpdate, + checkAppUpdateResult, + checkDuplicateInstallations, + removeDuplicateInstallations, + onDuplicateInstallationsRemoved, onAppUpdateProgress, onCheckForUpdatesRequest, getToken, diff --git a/src/desktop/appupdate.js b/src/desktop/appupdate.js index ca3790c..24f2b08 100644 --- a/src/desktop/appupdate.js +++ b/src/desktop/appupdate.js @@ -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) => { try { const settings = await getAppSettings(); @@ -273,6 +336,8 @@ const launchInstallerAndRestart = async ( }; const runAppUpdate = async (mainWindow, update, sendProgress) => { + await persistCurrentAppState(mainWindow); + const artifact = selectUpdateArtifact(update); const tempDirectory = await fs.mkdtemp( path.join(os.tmpdir(), "farmcontrol-update-"), diff --git a/src/desktop/check-duplicate-installations.js b/src/desktop/check-duplicate-installations.js new file mode 100644 index 0000000..31cf1cd --- /dev/null +++ b/src/desktop/check-duplicate-installations.js @@ -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 } +} diff --git a/src/desktop/rpc.js b/src/desktop/rpc.js index bc191f8..0b6bbdf 100644 --- a/src/desktop/rpc.js +++ b/src/desktop/rpc.js @@ -1,5 +1,9 @@ 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 { sendToRenderer } from './notify.js' import { resizeSpotlightWindow } from './spotlight.js' @@ -72,6 +76,24 @@ export function createAppRpc() { setSidebarViewMenu: async ({ 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', getAppEngine: async () => { const mainWindow = getMainWindow() diff --git a/src/desktop/updater-runner.js b/src/desktop/updater-runner.js index 8578db1..ad6cc23 100644 --- a/src/desktop/updater-runner.js +++ b/src/desktop/updater-runner.js @@ -23,7 +23,7 @@ const resolveExistingLauncher = (...candidates) => const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"` -const findMacAppBundle = (startPath) => { +export const findMacAppBundle = (startPath) => { let current = path.resolve(startPath) while (current !== path.dirname(current)) { diff --git a/src/electrobun-bridge.js b/src/electrobun-bridge.js index 16e7f50..ee5037c 100644 --- a/src/electrobun-bridge.js +++ b/src/electrobun-bridge.js @@ -181,6 +181,11 @@ const electronAPI = { getAppSettings: () => invokeRequest('getAppSettings'), setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }), startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }), + checkAppUpdateResult: () => invokeRequest('checkAppUpdateResult'), + checkDuplicateInstallations: () => + invokeRequest('checkDuplicateInstallations'), + removeDuplicateInstallations: () => + invokeRequest('removeDuplicateInstallations'), resizeSpotlightWindow: (height) => invokeRequest('resizeSpotlightWindow', { height }), setSidebarViewMenu: (sections) =>