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,
|
setAppSettings,
|
||||||
getAppEngine,
|
getAppEngine,
|
||||||
startAppUpdate,
|
startAppUpdate,
|
||||||
|
checkAppUpdateResult,
|
||||||
|
checkDuplicateInstallations,
|
||||||
|
removeDuplicateInstallations,
|
||||||
|
onDuplicateInstallationsRemoved,
|
||||||
onAppUpdateProgress,
|
onAppUpdateProgress,
|
||||||
onCheckForUpdatesRequest
|
onCheckForUpdatesRequest
|
||||||
} = useContext(ElectronContext)
|
} = useContext(ElectronContext)
|
||||||
@ -139,6 +143,11 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
|
||||||
const [installingUpdate, setInstallingUpdate] = useState(null)
|
const [installingUpdate, setInstallingUpdate] = useState(null)
|
||||||
const [updateProgress, setUpdateProgress] = 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 runningCheckRef = useRef(null)
|
||||||
const updateCheckDependenciesRef = useRef({})
|
const updateCheckDependenciesRef = useRef({})
|
||||||
|
|
||||||
@ -293,6 +302,44 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [isElectron, showUpdateIfAvailable])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isElectron || !onAppUpdateProgress) return undefined
|
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 updateModalOpen = Boolean(updatePromptOpen || installingUpdate)
|
||||||
const updateModalBusy =
|
const updateModalBusy =
|
||||||
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
|
||||||
@ -438,6 +504,100 @@ export const AppUpdateProvider = ({ children }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
</AppUpdateContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -296,6 +296,36 @@ const ElectronProvider = ({ children }) => {
|
|||||||
[electronAvailable, useElectrobun]
|
[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(
|
const onAppUpdateProgress = useCallback(
|
||||||
(handler) => {
|
(handler) => {
|
||||||
if (!electronAvailable || typeof handler !== 'function') {
|
if (!electronAvailable || typeof handler !== 'function') {
|
||||||
@ -419,6 +449,10 @@ const ElectronProvider = ({ children }) => {
|
|||||||
getAppSettings,
|
getAppSettings,
|
||||||
setAppSettings,
|
setAppSettings,
|
||||||
startAppUpdate,
|
startAppUpdate,
|
||||||
|
checkAppUpdateResult,
|
||||||
|
checkDuplicateInstallations,
|
||||||
|
removeDuplicateInstallations,
|
||||||
|
onDuplicateInstallationsRemoved,
|
||||||
onAppUpdateProgress,
|
onAppUpdateProgress,
|
||||||
onCheckForUpdatesRequest,
|
onCheckForUpdatesRequest,
|
||||||
getToken,
|
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) => {
|
const persistInstalledUpdateSettings = async (update) => {
|
||||||
try {
|
try {
|
||||||
const settings = await getAppSettings();
|
const settings = await getAppSettings();
|
||||||
@ -273,6 +336,8 @@ const launchInstallerAndRestart = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const runAppUpdate = async (mainWindow, update, sendProgress) => {
|
const runAppUpdate = async (mainWindow, update, sendProgress) => {
|
||||||
|
await persistCurrentAppState(mainWindow);
|
||||||
|
|
||||||
const artifact = selectUpdateArtifact(update);
|
const artifact = selectUpdateArtifact(update);
|
||||||
const tempDirectory = await fs.mkdtemp(
|
const tempDirectory = await fs.mkdtemp(
|
||||||
path.join(os.tmpdir(), "farmcontrol-update-"),
|
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 { 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 { setSidebarViewMenu } from './menu.js'
|
||||||
import { sendToRenderer } from './notify.js'
|
import { sendToRenderer } from './notify.js'
|
||||||
import { resizeSpotlightWindow } from './spotlight.js'
|
import { resizeSpotlightWindow } from './spotlight.js'
|
||||||
@ -72,6 +76,24 @@ export function createAppRpc() {
|
|||||||
setSidebarViewMenu: async ({ sections }) => ({
|
setSidebarViewMenu: async ({ sections }) => ({
|
||||||
ok: setSidebarViewMenu(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',
|
getAppVersion: async () => process.env.ELECTROBUN_VERSION || 'desktop',
|
||||||
getAppEngine: async () => {
|
getAppEngine: async () => {
|
||||||
const mainWindow = getMainWindow()
|
const mainWindow = getMainWindow()
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const resolveExistingLauncher = (...candidates) =>
|
|||||||
|
|
||||||
const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"`
|
const quoteBatchArg = (value) => `"${String(value).replaceAll('"', '""')}"`
|
||||||
|
|
||||||
const findMacAppBundle = (startPath) => {
|
export const findMacAppBundle = (startPath) => {
|
||||||
let current = path.resolve(startPath)
|
let current = path.resolve(startPath)
|
||||||
|
|
||||||
while (current !== path.dirname(current)) {
|
while (current !== path.dirname(current)) {
|
||||||
|
|||||||
@ -181,6 +181,11 @@ const electronAPI = {
|
|||||||
getAppSettings: () => invokeRequest('getAppSettings'),
|
getAppSettings: () => invokeRequest('getAppSettings'),
|
||||||
setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }),
|
setAppSettings: (settings) => invokeRequest('setAppSettings', { settings }),
|
||||||
startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }),
|
startAppUpdate: (update) => invokeRequest('startAppUpdate', { update }),
|
||||||
|
checkAppUpdateResult: () => invokeRequest('checkAppUpdateResult'),
|
||||||
|
checkDuplicateInstallations: () =>
|
||||||
|
invokeRequest('checkDuplicateInstallations'),
|
||||||
|
removeDuplicateInstallations: () =>
|
||||||
|
invokeRequest('removeDuplicateInstallations'),
|
||||||
resizeSpotlightWindow: (height) =>
|
resizeSpotlightWindow: (height) =>
|
||||||
invokeRequest('resizeSpotlightWindow', { height }),
|
invokeRequest('resizeSpotlightWindow', { height }),
|
||||||
setSidebarViewMenu: (sections) =>
|
setSidebarViewMenu: (sections) =>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user