Tom Butcher b254c0c94d Enhance AppUpdateProvider with improved duplicate installation handling
- Updated the state management for duplicate installations to include specific paths, enhancing clarity for users.
- Modified modal titles to include icons for better visual representation of update statuses.
- Adjusted modal properties for improved user experience during duplicate installation prompts.
2026-08-09 11:19:17 +01:00

643 lines
18 KiB
JavaScript

import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { Button, Modal, Space, Typography, Flex } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import { version as appVersion } from '../../../../package.json'
import { ApiServerContext } from './ApiServerContext'
import { AuthContext } from './AuthContext'
import { ElectronContext } from './ElectronContext'
import NewAppUpdate from '../Management/AppUpdates/NewAppUpdate'
import AppUpdateProgress from '../Management/AppUpdates/AppUpdateProgress'
import SoftwareUpdateIcon from '../../Icons/SoftwareUpdateIcon'
import ExclamationOctogonIcon from '../../Icons/ExclamationOctagonIcon'
const { Text } = Typography
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
const DEFAULT_MODEL_WIDTH = 710
const DEFAULT_UPDATE_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native'
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
const APP_UPDATE_DISMISSED_KEY = 'appUpdateDismissed'
export const normalizeAppUpdateEngine = (engine) => {
const value = String(engine || '')
.trim()
.toLowerCase()
if (value === 'chromium' || value === 'cef') return 'chromium'
if (value === 'native') return 'native'
return null
}
const getDismissedUpdate = () => {
try {
const stored = sessionStorage.getItem(APP_UPDATE_DISMISSED_KEY)
return stored ? JSON.parse(stored) : null
} catch {
return null
}
}
const isUpdateDismissed = (update) => {
if (!update) return false
const dismissed = getDismissedUpdate()
if (!dismissed) return false
return (
dismissed.version === update.version &&
dismissed.buildNumber === update.buildNumber &&
dismissed.branch === update.branch &&
normalizeAppUpdateEngine(dismissed.engine) ===
normalizeAppUpdateEngine(update.engine)
)
}
const saveDismissedUpdate = (update) => {
if (!update) return
sessionStorage.setItem(
APP_UPDATE_DISMISSED_KEY,
JSON.stringify({
version: update.version,
buildNumber: update.buildNumber,
branch: update.branch,
engine: normalizeAppUpdateEngine(update.engine) || DEFAULT_UPDATE_ENGINE
})
)
}
const AppUpdateContext = createContext()
// eslint-disable-next-line react-refresh/only-export-components
export const compareVersionNumbers = (leftVersion, rightVersion) => {
const leftParts = String(leftVersion || '0.0.0')
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
const rightParts = String(rightVersion || '0.0.0')
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const leftPart = leftParts[index] || 0
const rightPart = rightParts[index] || 0
if (leftPart > rightPart) return 1
if (leftPart < rightPart) return -1
}
return 0
}
const normalizeBuildNumber = (buildNumber) => {
const parsedBuildNumber = Number.parseInt(buildNumber, 10)
return Number.isNaN(parsedBuildNumber) ? 0 : parsedBuildNumber
}
// eslint-disable-next-line react-refresh/only-export-components
export const isAppUpdateAvailable = (update, currentVersion, currentBuild) => {
if (!update) return false
const versionComparison = compareVersionNumbers(
update.version,
currentVersion
)
if (versionComparison > 0) return true
if (versionComparison < 0) return false
return (
normalizeBuildNumber(update.buildNumber) >
normalizeBuildNumber(currentBuild)
)
}
export const AppUpdateProvider = ({ children }) => {
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
useContext(ApiServerContext)
const { token } = useContext(AuthContext)
const {
isElectron,
getAppSettings,
setAppSettings,
getAppEngine,
startAppUpdate,
checkAppUpdateResult,
checkDuplicateInstallations,
removeDuplicateInstallations,
onDuplicateInstallationsRemoved,
onAppUpdateProgress,
onCheckForUpdatesRequest
} = useContext(ElectronContext)
const [checking, setChecking] = useState(false)
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
const [availableUpdate, setAvailableUpdate] = useState(null)
const [updatePromptOpen, setUpdatePromptOpen] = useState(false)
const [installingUpdate, setInstallingUpdate] = useState(null)
const [updateProgress, setUpdateProgress] = useState(null)
const [completedUpdate, setCompletedUpdate] = useState(null)
const [duplicateInstall, setDuplicateInstall] = useState({
duplicatePath: 'C:\\Program Files\\Farm Control',
originalPath: 'C:\\Program Files\\Farm Control'
})
const [duplicatePromptOpen, setDuplicatePromptOpen] = useState(true)
const [removingDuplicate, setRemovingDuplicate] = useState(false)
const [duplicateRemovalResult, setDuplicateRemovalResult] = useState(null)
const runningCheckRef = useRef(null)
const updateCheckDependenciesRef = useRef({})
const [modelWidth, setModelWidth] = useState(DEFAULT_MODEL_WIDTH)
updateCheckDependenciesRef.current = {
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
getAppSettings,
setAppSettings,
getAppEngine,
isElectron,
token
}
const checkForAvailableUpdate = useCallback(async () => {
const {
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
getAppSettings,
setAppSettings,
getAppEngine,
isElectron,
token
} = updateCheckDependenciesRef.current
if (!isElectron || !token) return null
if (runningCheckRef.current) return runningCheckRef.current
const checkPromise = (async () => {
const [branches, appSettings, runningEngine] = await Promise.all([
fetchAppUpdateBranches(),
getAppSettings(),
getAppEngine()
])
const configuredBranch = appSettings?.appUpdateBranch
const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH)
? DEFAULT_UPDATE_BRANCH
: branches[0]
const selectedBranch = branches.includes(configuredBranch)
? configuredBranch
: defaultBranch
if (!selectedBranch) return null
const selectedEngine =
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
normalizeAppUpdateEngine(runningEngine) ||
DEFAULT_UPDATE_ENGINE
const currentRunningEngine =
normalizeAppUpdateEngine(runningEngine) || DEFAULT_UPDATE_ENGINE
const settingsUpdates = {}
if (!appSettings?.appUpdateRunningBranch) {
settingsUpdates.appUpdateRunningBranch = selectedBranch
}
if (!normalizeAppUpdateEngine(appSettings?.appUpdateEngine)) {
settingsUpdates.appUpdateEngine = selectedEngine
}
if (Object.keys(settingsUpdates).length > 0) {
await setAppSettings({
...appSettings,
...settingsUpdates
})
}
const runningBranch =
appSettings?.appUpdateRunningBranch ||
settingsUpdates.appUpdateRunningBranch ||
selectedBranch
const update = await fetchAppUpdateCurrent(selectedBranch)
if (!update) return null
const newerVersionAvailable = isAppUpdateAvailable(
update,
appVersion,
CURRENT_BUILD_NUMBER
)
const engineMismatch = selectedEngine !== currentRunningEngine
const branchMismatch = selectedBranch !== runningBranch
if (!newerVersionAvailable && !engineMismatch && !branchMismatch) {
return null
}
return {
...update,
branch: update.branch || selectedBranch,
engine: selectedEngine
}
})()
runningCheckRef.current = checkPromise
try {
const update = await checkPromise
return update
} finally {
runningCheckRef.current = null
}
}, [])
const showUpdateIfAvailable = useCallback(
async ({ forcePrompt = false } = {}) => {
const update = await checkForAvailableUpdate()
if (update) {
setNoUpdateOpen(false)
setAvailableUpdate(update)
if (forcePrompt || !isUpdateDismissed(update)) {
setModelWidth(DEFAULT_MODEL_WIDTH)
setUpdatePromptOpen(true)
}
}
return update
},
[checkForAvailableUpdate]
)
const checkForUpdates = useCallback(async () => {
if (!isElectron) return null
setChecking(true)
try {
const update = await showUpdateIfAvailable({ forcePrompt: true })
if (!update) {
setNoUpdateOpen(true)
}
return update
} finally {
setChecking(false)
}
}, [isElectron, showUpdateIfAvailable])
const recheckForUpdates = useCallback(async () => {
if (!isElectron) return null
return showUpdateIfAvailable({ forcePrompt: true })
}, [isElectron, showUpdateIfAvailable])
useEffect(() => {
if (!isElectron) return undefined
showUpdateIfAvailable()
const interval = window.setInterval(() => {
showUpdateIfAvailable()
}, UPDATE_CHECK_INTERVAL_MS)
return () => {
window.clearInterval(interval)
}
}, [isElectron, showUpdateIfAvailable])
useEffect(() => {
if (!isElectron) return undefined
let cancelled = false
const runStartupChecks = async () => {
let result = null
try {
result = await checkAppUpdateResult?.()
if (!cancelled && result?.updated) {
setCompletedUpdate(result)
}
} catch (error) {
console.warn('[AppUpdateContext] Startup update check failed:', error)
}
try {
const installations =
result?.duplicates || (await checkDuplicateInstallations?.())
if (!cancelled && installations?.duplicatePath) {
setDuplicateInstall(installations)
setDuplicatePromptOpen(true)
}
} catch (error) {
console.warn(
'[AppUpdateContext] Duplicate installation check 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
return onAppUpdateProgress((progress) => {
setUpdateProgress(progress)
})
}, [isElectron, onAppUpdateProgress])
useEffect(() => {
if (!isElectron || !onCheckForUpdatesRequest) return undefined
return onCheckForUpdatesRequest(() => {
void checkForUpdates()
})
}, [isElectron, onCheckForUpdatesRequest, checkForUpdates])
const dismissUpdatePrompt = () => {
if (availableUpdate) {
saveDismissedUpdate(availableUpdate)
}
setUpdatePromptOpen(false)
}
const closeUpdateModal = () => {
setUpdatePromptOpen(false)
setInstallingUpdate(null)
setUpdateProgress(null)
}
const handleUpdate = async (update) => {
setNoUpdateOpen(false)
setUpdatePromptOpen(false)
setInstallingUpdate(update)
setModelWidth(550)
setUpdateProgress({
phase: 'preparing',
percent: 0,
message: 'Preparing update'
})
try {
const appSettings = await getAppSettings()
const engine =
normalizeAppUpdateEngine(update?.engine) ||
normalizeAppUpdateEngine(appSettings?.appUpdateEngine) ||
DEFAULT_UPDATE_ENGINE
const result = await startAppUpdate({
...update,
engine
})
if (!result) {
throw new Error(
'Failed to start the app update. Please restart the app and try again.'
)
}
} catch (error) {
setUpdateProgress({
phase: 'error',
percent: null,
message: error?.message || 'Failed to start the app update.'
})
}
}
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'
return (
<AppUpdateContext.Provider
value={{ availableUpdate, checkForUpdates, recheckForUpdates }}
>
{children}
<Modal
open={checking}
className='loading-modal'
title={false}
height={20}
style={{ maxWidth: 260, top: '50%', transform: 'translateY(-50%)' }}
closable={false}
maskClosable={false}
footer={false}
>
<Space size='middle'>
<LoadingOutlined />
<Text style={{ margin: 0 }}>Checking for updates...</Text>
</Space>
</Modal>
<Modal
title={
<Flex align='center' gap='middle'>
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
Software Update
</Flex>
}
open={noUpdateOpen}
okText='OK'
style={{ maxWidth: 430 }}
centered
maskClosable
onOk={() => setNoUpdateOpen(false)}
onCancel={() => setNoUpdateOpen(false)}
footer={[
<Button
key='ok'
type='primary'
onClick={() => setNoUpdateOpen(false)}
>
OK
</Button>
]}
>
<Text>There are no new software updates available.</Text>
</Modal>
<Modal
title={
<Flex align='center' gap='middle'>
<SoftwareUpdateIcon style={{ fontSize: 18 }} />
{installingUpdate ? 'Software Update' : 'Software Update Available'}
</Flex>
}
open={updateModalOpen}
footer={null}
width={modelWidth}
centered
closable={!updateModalBusy}
maskClosable={!updateModalBusy}
onCancel={
updateModalBusy
? undefined
: installingUpdate
? closeUpdateModal
: dismissUpdatePrompt
}
>
{installingUpdate ? (
<AppUpdateProgress
progress={updateProgress}
update={installingUpdate}
onClose={closeUpdateModal}
/>
) : (
<NewAppUpdate
update={availableUpdate}
onCancel={dismissUpdatePrompt}
onUpdate={handleUpdate}
/>
)}
</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={
<Flex align='center' gap='middle'>
<ExclamationOctogonIcon />
Duplicate Installation Found
</Flex>
}
open={Boolean(duplicatePromptOpen && duplicateInstall)}
width={!removingDuplicate && !duplicateRemovalResult ? 560 : 430}
centered
closable={false}
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 code>{duplicateInstall?.duplicatePath}</Text>.{' '}
<Text>Do you want to remove the duplicate installation?</Text>
</Text>
</Space>
)}
</Modal>
</AppUpdateContext.Provider>
)
}
AppUpdateProvider.propTypes = {
children: PropTypes.node.isRequired
}
// eslint-disable-next-line react-refresh/only-export-components
export const useAppUpdateContext = () => {
const context = useContext(AppUpdateContext)
if (!context) {
throw new Error(
'useAppUpdateContext must be used within an AppUpdateProvider'
)
}
return context
}
export { AppUpdateContext }