All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
295 lines
9.5 KiB
JavaScript
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 }
|
|
}
|