- 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.
643 lines
18 KiB
JavaScript
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 }
|