farmcontrol-ui/src/desktop/check-duplicate-installations.js
Tom Butcher c5d8d9cf70
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Add duplicate installation checks and update handling in Electron context
2026-08-08 20:04:55 +01:00

295 lines
9.5 KiB
JavaScript

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