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 } }