Add duplicate installation checks and update handling in Electron context
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
This commit is contained in:
parent
e46e12f15d
commit
c5d8d9cf70
@ -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 }) => {
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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-"),
|
||||
|
||||
294
src/desktop/check-duplicate-installations.js
Normal file
294
src/desktop/check-duplicate-installations.js
Normal 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 }
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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) =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user