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 ( {children} Checking for updates... Software Update } open={noUpdateOpen} okText='OK' style={{ maxWidth: 430 }} centered maskClosable onOk={() => setNoUpdateOpen(false)} onCancel={() => setNoUpdateOpen(false)} footer={[ ]} > There are no new software updates available. {installingUpdate ? 'Software Update' : 'Software Update Available'} } open={updateModalOpen} footer={null} width={modelWidth} centered closable={!updateModalBusy} maskClosable={!updateModalBusy} onCancel={ updateModalBusy ? undefined : installingUpdate ? closeUpdateModal : dismissUpdatePrompt } > {installingUpdate ? ( ) : ( )} Update Installed } open={Boolean(completedUpdate)} style={{ maxWidth: 430 }} centered onCancel={() => setCompletedUpdate(null)} footer={[ ]} > Farm Control was successfully updated to version{' '} {completedUpdate?.current?.version || appVersion} {completedUpdate?.previous?.version && completedUpdate.previous.version !== completedUpdate?.current?.version ? ` (previously ${completedUpdate.previous.version})` : ''} . Duplicate Installation Found } open={Boolean(duplicatePromptOpen && duplicateInstall)} width={!removingDuplicate && !duplicateRemovalResult ? 560 : 430} centered closable={false} maskClosable={false} onCancel={removingDuplicate ? undefined : closeDuplicatePrompt} footer={ removingDuplicate ? null : duplicateRemovalResult ? [ ] : [ , ] } > {removingDuplicate ? ( Removing the duplicate installation... You may be asked for an administrator password. ) : duplicateRemovalResult ? ( {duplicateRemovalResult.ok ? 'The duplicate installation was removed.' : duplicateRemovalResult.error || 'Failed to remove the duplicate installation.'} ) : ( Farm Control is now installed in your user applications folder, but an older copy is still installed at:{' '} {duplicateInstall?.duplicatePath}.{' '} Do you want to remove the duplicate installation? )} ) } 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 }