Implemented software update installation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

This commit is contained in:
Tom Butcher 2026-06-21 15:18:04 +01:00
parent ea57ba65f3
commit 78dc567a8f
15 changed files with 1362 additions and 374 deletions

View File

@ -38,6 +38,7 @@
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.9.1",
"@uiw/react-codemirror": "^4.25.1",
"@vscode/sudo-prompt": "^9.3.2",
"antd": "^5.27.1",
"antd-style": "^3.7.1",
"axios": "^1.11.0",

8
pnpm-lock.yaml generated
View File

@ -95,6 +95,9 @@ importers:
'@uiw/react-codemirror':
specifier: ^4.25.1
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.3)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.12)(codemirror@6.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vscode/sudo-prompt':
specifier: ^9.3.2
version: 9.3.2
antd:
specifier: ^5.27.1
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -2125,6 +2128,9 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vscode/sudo-prompt@9.3.2':
resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==}
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
@ -8306,6 +8312,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vscode/sudo-prompt@9.3.2': {}
'@xmldom/xmldom@0.8.11': {}
'@zeit/schemas@2.36.0': {}

View File

@ -0,0 +1,398 @@
import { ipcMain } from 'electron'
import { createWriteStream, promises as fs } from 'fs'
import { spawn } from 'child_process'
import { createRequire } from 'module'
import http from 'http'
import https from 'https'
import os from 'os'
import path from 'path'
import process from 'process'
const require = createRequire(import.meta.url)
const sudo = require('@vscode/sudo-prompt')
const UPDATE_PROGRESS_CHANNEL = 'app-update-progress'
const SUPPORTED_TARGETS = {
darwin: {
extension: '.pkg',
osMatchers: ['darwin', 'mac', 'macos', 'osx']
},
win32: {
extension: '.msi',
osMatchers: ['win32', 'win', 'windows']
}
}
let runningUpdate = null
const getArtifactName = (artifact) =>
String(artifact?.fileName || artifact?.relativePath || artifact?.url || '')
const normalizeArch = (arch) => {
if (arch === 'x64' || arch === 'amd64') return 'x64'
if (arch === 'arm64' || arch === 'aarch64') return 'arm64'
return arch
}
const artifactMatchesPlatform = (artifact, target, platform, arch) => {
const name = getArtifactName(artifact).toLowerCase()
const normalizedArch = normalizeArch(arch)
const artifactArch = normalizeArch(String(artifact?.arch || '').toLowerCase())
const artifactPlatform = String(
artifact?.platform || artifact?.os || artifact?.target || ''
).toLowerCase()
if (!name.endsWith(target.extension)) return false
if (!artifact?.url) return false
const matchesArch =
artifactArch === normalizedArch ||
name.includes(`-${normalizedArch}`) ||
name.includes(`_${normalizedArch}`) ||
name.includes(`.${normalizedArch}.`) ||
name.includes(normalizedArch)
const matchesOs =
!artifactPlatform ||
target.osMatchers.includes(artifactPlatform) ||
target.osMatchers.some((matcher) => name.includes(matcher)) ||
(platform === 'darwin' && name.includes('mac')) ||
(platform === 'win32' && name.includes('win'))
return matchesArch && matchesOs
}
const selectUpdateArtifact = (
update,
platform = process.platform,
arch = process.arch
) => {
const target = SUPPORTED_TARGETS[platform]
if (!target) {
throw new Error(`App updates are not supported on ${platform}.`)
}
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
const matchingArtifact = artifacts.find((artifact) =>
artifactMatchesPlatform(artifact, target, platform, arch)
)
const fallbackArtifact = artifacts.find((artifact) => {
const name = getArtifactName(artifact).toLowerCase()
return artifact?.url && name.endsWith(target.extension)
})
if (!matchingArtifact && !fallbackArtifact) {
throw new Error(
`No ${target.extension} update artifact found for ${platform}/${arch}.`
)
}
return matchingArtifact || fallbackArtifact
}
const sendProgress = (webContents, payload) => {
if (!webContents || webContents.isDestroyed()) return
webContents.send(UPDATE_PROGRESS_CHANNEL, {
timestamp: new Date().toISOString(),
...payload
})
}
const getDownloadUrl = (url, redirectCount = 0) =>
new Promise((resolve, reject) => {
if (redirectCount > 5) {
reject(new Error('Too many redirects while downloading update.'))
return
}
const parsedUrl = new URL(url)
const client = parsedUrl.protocol === 'https:' ? https : http
const request = client.get(parsedUrl, (response) => {
const location = response.headers.location
if (response.statusCode >= 300 && response.statusCode < 400 && location) {
response.resume()
resolve(
getDownloadUrl(
new URL(location, parsedUrl).toString(),
redirectCount + 1
)
)
return
}
resolve({ response, url: parsedUrl.toString() })
})
request.on('error', reject)
})
const downloadArtifact = async (artifact, destinationPath, webContents) => {
const { response } = await getDownloadUrl(artifact.url)
if (response.statusCode < 200 || response.statusCode >= 300) {
response.resume()
throw new Error(`Update download failed with HTTP ${response.statusCode}.`)
}
const totalBytes =
Number.parseInt(response.headers['content-length'], 10) || 0
let downloadedBytes = 0
await new Promise((resolve, reject) => {
const output = createWriteStream(destinationPath)
response.on('data', (chunk) => {
downloadedBytes += chunk.length
const percent = totalBytes
? Math.round((downloadedBytes / totalBytes) * 100)
: null
sendProgress(webContents, {
phase: 'downloading',
percent,
downloadedBytes,
totalBytes,
message: totalBytes
? `Downloading update (${percent}%)`
: 'Downloading update'
})
})
response.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
response.pipe(output)
})
}
const getMacAppPath = (app) => {
const executablePath = app.getPath('exe')
const appPathIndex = executablePath.indexOf('.app/')
if (appPathIndex === -1) return null
return executablePath.slice(0, appPathIndex + 4)
}
const quoteShellArg = (value) => `'${String(value).replaceAll("'", "'\\''")}'`
const getInstallErrorMessage = (error, output = '') => {
const combined = `${output}\n${error?.message || ''}`.trim()
if (
/cancel/i.test(combined) ||
/did not grant permission/i.test(combined) ||
/user canceled/i.test(combined)
) {
return 'Update installation was cancelled.'
}
if (/incorrect/i.test(combined)) {
return 'The administrator password was incorrect.'
}
return combined || 'Failed to install update.'
}
const buildMacInstallScript = (app, installerPath) => {
const appPath = getMacAppPath(app)
const relaunchCommand = appPath
? `open ${quoteShellArg(appPath)}`
: `open ${quoteShellArg(app.getPath('exe'))}`
return `sleep 2 && /usr/sbin/installer -pkg ${quoteShellArg(
installerPath
)} -target / && ${relaunchCommand}`
}
const buildWindowsInstallCommand = (app, installerPath) => ({
command: 'cmd.exe',
args: [
'/d',
'/s',
'/c',
`timeout /t 2 /nobreak >NUL && msiexec.exe /i "${installerPath}" /qn /norestart && start "" "${app.getPath(
'exe'
)}"`
]
})
const launchMacInstaller = (app, installerPath, webContents) => {
const installScript = buildMacInstallScript(app, installerPath)
const promptName = app.getName() || 'Farm Control'
console.log('[app-update] launching macOS installer:', {
installerPath,
installScript,
promptName
})
sendProgress(webContents, {
phase: 'installing',
percent: 100,
message:
'Installing update. Enter your Mac password when prompted, then Farm Control will restart automatically.'
})
app.focus({ steal: true })
app.dock?.show()
return new Promise((resolve, reject) => {
sudo.exec(installScript, { name: promptName }, (error, stdout, stderr) => {
const output = `${stdout || ''}${stderr || ''}`
if (stdout) console.log('[app-update] installer stdout:', stdout)
if (stderr) console.error('[app-update] installer stderr:', stderr)
if (error) {
console.error('[app-update] installer error:', error)
const message = getInstallErrorMessage(error, output)
sendProgress(webContents, {
phase: 'error',
percent: null,
message
})
reject(new Error(message))
return
}
console.log('[app-update] installer completed successfully')
resolve()
})
})
}
const launchWindowsInstaller = (app, installerPath, webContents) => {
const { command, args } = buildWindowsInstallCommand(app, installerPath)
console.log('[app-update] launching installer:', {
installerPath,
command,
args,
shellCommand: args.join(' '),
platform: process.platform
})
sendProgress(webContents, {
phase: 'installing',
percent: 100,
message: 'Installing update. Farm Control will restart automatically.'
})
let installerOutput = ''
const installerProcess = spawn(command, args, {
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
installerProcess.stdout?.on('data', (data) => {
const text = data.toString()
installerOutput += text
console.log('[app-update] installer stdout:', text)
})
installerProcess.stderr?.on('data', (data) => {
const text = data.toString()
installerOutput += text
console.error('[app-update] installer stderr:', text)
})
installerProcess.on('spawn', () => {
console.log('[app-update] installer spawned, pid:', installerProcess.pid)
})
installerProcess.on('error', (error) => {
console.error('[app-update] installer spawn error:', error)
sendProgress(webContents, {
phase: 'error',
percent: null,
message: error?.message || 'Failed to start update installer.'
})
})
installerProcess.on('exit', (code, signal) => {
console.log('[app-update] installer exited:', {
code,
signal,
output: installerOutput
})
if (code !== 0) {
sendProgress(webContents, {
phase: 'error',
percent: null,
message: getInstallErrorMessage(null, installerOutput)
})
}
})
installerProcess.unref()
}
const launchInstallerAndQuit = async (app, installerPath, webContents) => {
if (process.platform === 'darwin') {
await launchMacInstaller(app, installerPath, webContents)
return
}
if (process.platform === 'win32') {
launchWindowsInstaller(app, installerPath, webContents)
return
}
throw new Error(`App updates are not supported on ${process.platform}.`)
}
const runAppUpdate = async (app, update, webContents) => {
const artifact = selectUpdateArtifact(update)
const tempDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'farmcontrol-update-')
)
const artifactName = path.basename(getArtifactName(artifact))
const installerPath = path.join(tempDirectory, artifactName)
sendProgress(webContents, {
phase: 'preparing',
percent: 0,
artifact,
message: 'Preparing update download'
})
await downloadArtifact(artifact, installerPath, webContents)
sendProgress(webContents, {
phase: 'downloaded',
percent: 100,
downloadedBytes: null,
totalBytes: null,
artifact,
message: 'Update downloaded'
})
await launchInstallerAndQuit(app, installerPath, webContents)
}
export function setupAppUpdateIPC(app) {
ipcMain.handle('app-update-start', async (event, update) => {
if (runningUpdate) return runningUpdate
const webContents = event.sender
runningUpdate = runAppUpdate(app, update, webContents)
.then(() => ({ ok: true }))
.catch((error) => {
sendProgress(webContents, {
phase: 'error',
percent: null,
message: error?.message || 'Failed to update app.'
})
throw error
})
.finally(() => {
runningUpdate = null
})
return runningUpdate
})
}

View File

@ -1,5 +1,7 @@
import { app, ipcMain, shell, globalShortcut, safeStorage } from 'electron'
import Store from 'electron-store'
import { Buffer } from 'buffer'
import process from 'process'
import {
registerGlobalShortcuts,
setupSpotlightIPC
@ -12,12 +14,17 @@ import {
setupSingleInstanceLock,
handleDeepLinkFromArgv
} from './mainWindow.js'
import { setupAppUpdateIPC } from './appupdate.js'
// --- Auth session storage (main process) ---
const authStore = new Store({
name: 'auth-session'
})
const AUTH_SESSION_KEY = 'authSession'
const appSettingsStore = new Store({
name: 'settings'
})
const APP_SETTINGS_KEY = 'appSettings'
const serializeAuthSession = (session) => {
const sessionJson = JSON.stringify(session)
@ -76,6 +83,7 @@ if (gotTheLock) {
registerGlobalShortcuts()
setupSpotlightIPC()
setupMainWindowIPC()
setupAppUpdateIPC(app)
setupMainWindowAppEvents(app)
setupDevAuthServer()
handleDeepLinkFromArgv()
@ -124,6 +132,27 @@ ipcMain.handle('auth-session-clear', async () => {
}
})
ipcMain.handle('app-settings-get', async () => {
try {
const settings = appSettingsStore.get(APP_SETTINGS_KEY)
return settings && typeof settings === 'object' ? settings : {}
} catch (e) {
console.warn('[app-settings] Failed to read settings.', e?.message || e)
return {}
}
})
ipcMain.handle('app-settings-set', async (event, settings) => {
try {
if (!settings || typeof settings !== 'object') return false
appSettingsStore.set(APP_SETTINGS_KEY, settings)
return true
} catch (e) {
console.warn('[app-settings] Failed to write settings.', e?.message || e)
return false
}
})
// IPC handler for opening external URLs
ipcMain.handle('open-external-url', (event, url) => {
shell.openExternal(url)

View File

@ -28,6 +28,7 @@ import { ApiServerProvider } from './components/Dashboard/context/ApiServerConte
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
import { MessageProvider } from './components/Dashboard/context/MessageContext.jsx'
import { AppUpdateProvider } from './components/Dashboard/context/AppUpdateContext.jsx'
import AuthCallback from './components/App/AuthCallback.jsx'
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
@ -76,73 +77,80 @@ const AppContent = () => {
<PrintServerProvider>
<ApiServerProvider>
<MessageProvider>
<NotificationProvider>
<SpotlightProvider>
<ActionsModalProvider>
<Routes>
<Route path='/applaunch' element={<AuthLaunch />} />
<Route
path='/dashboard/electron/spotlightcontent'
element={
<PrivateRoute
component={() => (
<ElectronSpotlightContentPage />
)}
/>
}
/>
<Route
path='/'
element={
<PrivateRoute
component={() => (
<Navigate
to='/dashboard/production/overview'
replace
/>
)}
/>
}
/>
<Route
path='/auth/callback'
element={<AuthCallback />}
/>
<Route
path='/auth/marketplace/callback'
element={<MarketplaceAuthCallback />}
/>
<Route
path='/email/notification'
element={<EmailNotificationTemplate />}
/>
<AppUpdateProvider>
<NotificationProvider>
<SpotlightProvider>
<ActionsModalProvider>
<Routes>
<Route
path='/applaunch'
element={<AuthLaunch />}
/>
<Route
path='/dashboard/electron/spotlightcontent'
element={
<PrivateRoute
component={() => (
<ElectronSpotlightContentPage />
)}
/>
}
/>
<Route
path='/'
element={
<PrivateRoute
component={() => (
<Navigate
to='/dashboard/production/overview'
replace
/>
)}
/>
}
/>
<Route
path='/auth/callback'
element={<AuthCallback />}
/>
<Route
path='/auth/marketplace/callback'
element={<MarketplaceAuthCallback />}
/>
<Route
path='/email/notification'
element={<EmailNotificationTemplate />}
/>
<Route
path='/dashboard'
element={
<PrivateRoute component={() => <Dashboard />} />
}
>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{DeveloperRoutes}
</Route>
<Route
path='*'
element={
<AppError
message='Error 404. Page not found.'
showRefresh={false}
/>
}
/>
</Routes>
</ActionsModalProvider>
</SpotlightProvider>
</NotificationProvider>
<Route
path='/dashboard'
element={
<PrivateRoute
component={() => <Dashboard />}
/>
}
>
{ProductionRoutes}
{InventoryRoutes}
{FinanceRoutes}
{SalesRoutes}
{ManagementRoutes}
{DeveloperRoutes}
</Route>
<Route
path='*'
element={
<AppError
message='Error 404. Page not found.'
showRefresh={false}
/>
}
/>
</Routes>
</ActionsModalProvider>
</SpotlightProvider>
</NotificationProvider>
</AppUpdateProvider>
</MessageProvider>
</ApiServerProvider>
</PrintServerProvider>

View File

@ -12,11 +12,13 @@ import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse'
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
import ReloadIcon from '../../Icons/ReloadIcon'
import DownloadIcon from '../../Icons/DownloadIcon'
import DeveloperIcon from '../../Icons/DeveloperIcon'
import { version as appVersion } from '../../../../package.json'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { ElectronContext } from '../context/ElectronContext'
import { AppUpdateContext } from '../context/AppUpdateContext'
import { useMediaQuery } from 'react-responsive'
const { Title, Text, Link } = Typography
@ -25,25 +27,37 @@ const About = () => {
updater: true
})
const { token } = useContext(AuthContext)
const actions = [
{
label: 'Check for Updates',
icon: <ReloadIcon />,
onClick: () => {
console.log('Check for Updates')
}
}
]
const { fetchApiServerVersion, fetchWsServerVersion } =
useContext(ApiServerContext)
const { isElectron, getElectronVersion } = useContext(ElectronContext)
const { checkForUpdates } = useContext(AppUpdateContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const buildNumber = import.meta.env.VITE_BUILD_NUMBER
? 'b' + import.meta.env.VITE_BUILD_NUMBER
: 'dev'
const developmentMode = import.meta.env.MODE === 'development'
const { fetchApiServerVersion, fetchWsServerVersion } =
useContext(ApiServerContext)
const { isElectron, getElectronVersion } = useContext(ElectronContext)
const isMobile = useMediaQuery({ maxWidth: 768 })
const actions = [
{
label: 'Reload Window',
icon: <ReloadIcon />,
onClick: () => {
window.location.reload()
}
}
]
if (isElectron) {
actions.unshift(
{
label: 'Check for Updates',
icon: <DownloadIcon />,
onClick: checkForUpdates
},
{ type: 'divider' }
)
}
useEffect(() => {
if (token) {

View File

@ -1,186 +0,0 @@
import { useContext, useEffect, useMemo, useState } from 'react'
import {
Button,
Collapse,
Descriptions,
Empty,
Flex,
Select,
Space,
Typography
} from 'antd'
import { CaretLeftOutlined } from '@ant-design/icons'
import { ApiServerContext } from '../context/ApiServerContext'
import useCollapseState from '../hooks/useCollapseState'
const { Title, Text, Link } = Typography
const { Option } = Select
const AppUpdate = () => {
const { fetchAppUpdateBranches, fetchAppUpdateCurrent } =
useContext(ApiServerContext)
const [collapseState, updateCollapseState] = useCollapseState('AppUpdate', {
updater: true
})
const [branches, setBranches] = useState([])
const [selectedBranch, setSelectedBranch] = useState(undefined)
const [branchLoading, setBranchLoading] = useState(false)
const [checking, setChecking] = useState(false)
const [currentUpdate, setCurrentUpdate] = useState(null)
useEffect(() => {
const loadBranches = async () => {
setBranchLoading(true)
const availableBranches = await fetchAppUpdateBranches()
setBranches(availableBranches)
if (availableBranches.length > 0) {
setSelectedBranch((previous) =>
previous && availableBranches.includes(previous)
? previous
: availableBranches[0]
)
}
setBranchLoading(false)
}
loadBranches()
}, [fetchAppUpdateBranches])
const branchOptions = useMemo(
() =>
branches.map((branch) => (
<Option key={branch} value={branch}>
{branch}
</Option>
)),
[branches]
)
const handleCheckForUpdates = async () => {
if (!selectedBranch) return
setChecking(true)
const updateData = await fetchAppUpdateCurrent(selectedBranch)
setCurrentUpdate(updateData)
setChecking(false)
}
const buildTimestamp = currentUpdate?.buildTimestamp
? new Date(currentUpdate.buildTimestamp).toLocaleString()
: 'Unknown'
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Flex vertical gap='large'>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.updater ? ['1'] : []}
onChange={(keys) => updateCollapseState('updater', keys.length > 0)}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Application Updater
</Title>
</Flex>
}
key='1'
>
<Descriptions bordered column={1}>
<Descriptions.Item label='Branch'>
<Select
value={selectedBranch}
onChange={setSelectedBranch}
style={{ width: '100%' }}
loading={branchLoading}
placeholder='Select a branch'
>
{branchOptions}
</Select>
</Descriptions.Item>
<Descriptions.Item label='Actions'>
<Button
type='primary'
onClick={handleCheckForUpdates}
loading={checking}
disabled={!selectedBranch}
>
Check for Updates
</Button>
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16 }}>
{currentUpdate ? (
<Descriptions bordered column={1} title='Latest Build'>
<Descriptions.Item label='Branch'>
{currentUpdate.branch || selectedBranch}
</Descriptions.Item>
<Descriptions.Item label='Build Number'>
{currentUpdate.buildNumber || 'Unknown'}
</Descriptions.Item>
<Descriptions.Item label='Build Source'>
{currentUpdate.buildSource || 'Unknown'}
</Descriptions.Item>
<Descriptions.Item label='Build Status'>
{currentUpdate.buildResult || 'Unknown'}
</Descriptions.Item>
<Descriptions.Item label='Build Time'>
{buildTimestamp}
</Descriptions.Item>
<Descriptions.Item label='Build URL'>
{currentUpdate.buildUrl ? (
<Link href={currentUpdate.buildUrl} target='_blank'>
Open Jenkins Build
</Link>
) : (
<Text type='secondary'>No build URL available</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Artifacts'>
{Array.isArray(currentUpdate.artifacts) &&
currentUpdate.artifacts.length > 0 ? (
<Space direction='vertical'>
{currentUpdate.artifacts.map((artifact) => (
<Link
key={artifact.url}
href={artifact.url}
target='_blank'
>
{artifact.fileName || artifact.relativePath}
</Link>
))}
</Space>
) : (
<Text type='secondary'>No artifacts published</Text>
)}
</Descriptions.Item>
</Descriptions>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description='No update check has been run yet'
/>
)}
</div>
</Collapse.Panel>
</Collapse>
</Flex>
</div>
)
}
export default AppUpdate

View File

@ -0,0 +1,97 @@
import PropTypes from 'prop-types'
import { Alert, Flex, Progress, Typography } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
const { Text } = Typography
const formatBytes = (bytes) => {
if (!Number.isFinite(bytes) || bytes <= 0) return null
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${
units[unitIndex]
}`
}
const getProgressStatus = (phase) => {
if (phase === 'error') return 'exception'
if (phase === 'downloaded' || phase === 'installing') return 'success'
return 'active'
}
const AppUpdateProgress = ({ progress, update }) => {
const phase = progress?.phase || 'preparing'
const percent =
typeof progress?.percent === 'number' ? Math.min(progress.percent, 100) : 0
const downloaded = formatBytes(progress?.downloadedBytes)
const total = formatBytes(progress?.totalBytes)
const artifactName =
progress?.artifact?.fileName ||
progress?.artifact?.relativePath ||
'Selected installer'
const message = progress?.message || 'Preparing update'
const isInstalling = phase === 'installing'
const isError = phase === 'error'
return (
<Flex vertical gap='middle'>
<Text>
Updating Farm Control to version{' '}
{update?.version ? `${update.version}` : 'unknown'} build{' '}
{update?.buildNumber ? `${update.buildNumber}` : 'unknown'} from branch{' '}
{update?.branch ? `${update.branch}` : 'unknown'}...
</Text>
{isError ? (
<Alert type='error' showIcon message={message} />
) : (
<Alert
type={isInstalling ? 'success' : 'info'}
showIcon
icon={isInstalling ? undefined : <LoadingOutlined />}
message={
isInstalling
? 'The app will close while the installer runs, then reopen automatically.'
: `Downloading ${artifactName}`
}
/>
)}
{!isInstalling && (
<Progress
percent={percent}
status={getProgressStatus(phase)}
showInfo={phase !== 'preparing'}
/>
)}
{downloaded && total && (
<Text type='secondary'>
{downloaded} of {total} downloaded
</Text>
)}
</Flex>
)
}
AppUpdateProgress.propTypes = {
progress: PropTypes.shape({
phase: PropTypes.string,
percent: PropTypes.number,
downloadedBytes: PropTypes.number,
totalBytes: PropTypes.number,
message: PropTypes.string,
artifact: PropTypes.object
}),
update: PropTypes.object
}
export default AppUpdateProgress

View File

@ -0,0 +1,74 @@
import PropTypes from 'prop-types'
import { Button, Flex, Typography, Card } from 'antd'
import TimeDisplay from '../../common/TimeDisplay'
const { Text, Title } = Typography
const NewAppUpdate = ({ update, onCancel, onUpdate }) => {
const artifacts = Array.isArray(update?.artifacts) ? update.artifacts : []
const primaryArtifact = artifacts.find((artifact) => artifact.url)
return (
<Flex vertical gap='middle'>
<Text>
A new Farm Control update is available. Would you like to update now?
</Text>
<Card styles={{ body: { padding: 12 } }}>
<Flex gap={12}>
<img
src={'/logo512.png'}
alt='Farm Control Logo'
style={{ width: '70px', height: '70px' }}
/>
<Flex vertical gap={2} justify='center'>
<Title level={3} style={{ margin: 0 }}>
{'Farm Control UI'}
</Title>
<Flex style={{ columnGap: '15px', rowGap: '8px' }}>
<Text style={{ margin: 0 }} type='secondary'>
Version:{' '}
<Text>
{update?.version ? `v${update.version}` : 'Unknown'}
</Text>
</Text>
<Text style={{ margin: 0 }} type='secondary'>
Build Number:{' '}
<Text>
{update?.buildNumber ? `b${update.buildNumber}` : 'Unknown'}
</Text>
</Text>
<Text style={{ margin: 0 }} type='secondary'>
Branch: <Text>{update?.branch || 'Unknown'}</Text>
</Text>
</Flex>
</Flex>
</Flex>
</Card>
<Flex justify='space-between' gap='small' align='center'>
<Flex gap='small'>
<Text type='secondary'>Built at:</Text>
<TimeDisplay dateTime={update?.builtAt} />
</Flex>
<Flex gap='small'>
<Button onClick={onCancel}>Not Now</Button>
<Button
type='primary'
onClick={() => onUpdate(update)}
disabled={!primaryArtifact}
>
Update Now
</Button>
</Flex>
</Flex>
</Flex>
)
}
NewAppUpdate.propTypes = {
update: PropTypes.object,
onCancel: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired
}
export default NewAppUpdate

View File

@ -1,121 +1,309 @@
import { Select, Typography, Descriptions, Collapse, Flex } from 'antd'
import { CaretLeftOutlined } from '@ant-design/icons'
import { useContext, useEffect, useMemo, useState } from 'react'
import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd'
import { LoadingOutlined, SettingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext'
import { ApiServerContext } from '../context/ApiServerContext'
import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext'
import { useMessageContext } from '../context/MessageContext'
import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse'
import ViewButton from '../common/ViewButton'
import EditButtons from '../common/EditButtons'
const { Title } = Typography
const { Text } = Typography
const { Option } = Select
const DEFAULT_UPDATE_BRANCH = 'main'
const Settings = () => {
const {
isDarkMode,
toggleTheme,
isCompact,
toggleCompact,
isSystem,
toggleSystem
} = useThemeContext()
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
useThemeContext()
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
const { isElectron, getAppSettings, setAppSettings } =
useContext(ElectronContext)
const { userProfile, setUserProfile } = useContext(AuthContext)
const { showSuccess, showError } = useMessageContext()
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
appearance: true
appearance: true,
appUpdates: true
})
const [isEditing, setIsEditing] = useState(false)
const [settingsLoading, setSettingsLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [appSettings, setAppSettingsState] = useState({})
const [draftSettings, setDraftSettings] = useState({})
const [branches, setBranches] = useState([])
const [branchLoading, setBranchLoading] = useState(false)
const handleThemeChange = (value) => {
if (value === 'system') {
toggleSystem()
} else {
if (isSystem) {
toggleSystem()
}
if (value === 'dark' && !isDarkMode) {
toggleTheme()
} else if (value === 'light' && isDarkMode) {
toggleTheme()
}
useEffect(() => {
const loadSettings = async () => {
setSettingsLoading(true)
const storedSettings = isElectron
? await getAppSettings()
: userProfile?.settings || {}
setAppSettingsState(storedSettings || {})
setDraftSettings(storedSettings || {})
setSettingsLoading(false)
}
}
const handleCompactChange = (value) => {
if (value === 'compact' && !isCompact) {
toggleCompact()
} else if (value === 'comfortable' && isCompact) {
toggleCompact()
loadSettings()
}, [getAppSettings, isElectron, userProfile?.settings])
useEffect(() => {
if (settingsLoading || isEditing) return
if (appSettings.theme) setThemeMode(appSettings.theme)
if (appSettings.density) setDensityMode(appSettings.density)
}, [
appSettings.density,
appSettings.theme,
isEditing,
setDensityMode,
setThemeMode,
settingsLoading
])
useEffect(() => {
if (!isElectron) {
setBranches([])
setBranchLoading(false)
return
}
}
const loadBranches = async () => {
setBranchLoading(true)
const availableBranches = await fetchAppUpdateBranches()
setBranches(availableBranches)
setDraftSettings((previous) => {
if (previous.appUpdateBranch) return previous
const defaultBranch = availableBranches.includes(DEFAULT_UPDATE_BRANCH)
? DEFAULT_UPDATE_BRANCH
: availableBranches[0]
return defaultBranch
? { ...previous, appUpdateBranch: defaultBranch }
: previous
})
setBranchLoading(false)
}
loadBranches()
}, [fetchAppUpdateBranches, isElectron])
const branchOptions = useMemo(
() =>
branches.map((branch) => (
<Option key={branch} value={branch}>
{branch}
</Option>
)),
[branches]
)
const viewItems = [
{ key: 'appearance', label: 'Appearance Settings' },
...(isElectron ? [{ key: 'appUpdates', label: 'App Update Settings' }] : [])
]
const getCurrentThemeValue = () => {
if (isSystem) return 'system'
return isDarkMode ? 'dark' : 'light'
}
return (
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Flex vertical gap={'large'}>
<Collapse
ghost
expandIconPosition='end'
activeKey={collapseState.appearance ? ['1'] : []}
onChange={(keys) =>
updateCollapseState('appearance', keys.length > 0)
const currentThemeValue = getCurrentThemeValue()
const currentDensityValue = isCompact ? 'compact' : 'comfortable'
const currentBranch =
appSettings.appUpdateBranch ||
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
branches[0] ||
'Not configured'
const startEditing = () => {
setDraftSettings({
...appSettings,
appUpdateBranch:
currentBranch === 'Not configured' ? undefined : currentBranch,
theme: currentThemeValue,
density: currentDensityValue
})
setIsEditing(true)
}
const cancelEditing = () => {
setDraftSettings(appSettings)
setIsEditing(false)
}
const handleSave = async () => {
setSaving(true)
try {
const nextSettings = {
...appSettings,
theme: draftSettings.theme,
density: draftSettings.density,
...(isElectron
? { appUpdateBranch: draftSettings.appUpdateBranch }
: {})
}
const saved = isElectron
? await setAppSettings(nextSettings)
: Boolean(userProfile)
if (!saved) {
showError('Unable to save settings.')
return
}
if (!isElectron) {
setUserProfile((previous) => ({
...previous,
settings: {
...(previous?.settings || {}),
theme: draftSettings.theme,
density: draftSettings.density
}
expandIcon={({ isActive }) => (
<CaretLeftOutlined
rotate={isActive ? 90 : 0}
style={{ paddingTop: '9px' }}
/>
)}
className='no-h-padding-collapse'
>
<Collapse.Panel
header={
<Flex
align='center'
justify='space-between'
style={{ width: '100%' }}
>
<Title level={5} style={{ margin: 0 }}>
Appearance Settings
</Title>
</Flex>
}
key='1'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Theme'>
<Select
value={getCurrentThemeValue()}
onChange={handleThemeChange}
style={{ width: '100%' }}
>
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
<Select
value={isCompact ? 'compact' : 'comfortable'}
onChange={handleCompactChange}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
</Collapse>
}))
}
setThemeMode(draftSettings.theme)
setDensityMode(draftSettings.density)
setAppSettingsState(nextSettings)
setDraftSettings(nextSettings)
setIsEditing(false)
showSuccess('Settings saved.')
} finally {
setSaving(false)
}
}
return (
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
<Flex justify='space-between' align='center'>
<Space size='small'>
<ViewButton
disabled={settingsLoading}
items={viewItems}
visibleState={collapseState}
updateVisibleState={updateCollapseState}
/>
</Space>
<EditButtons
isEditing={isEditing}
handleUpdate={handleSave}
cancelEditing={cancelEditing}
startEditing={startEditing}
formValid={
Boolean(draftSettings.theme && draftSettings.density) &&
(!isElectron || Boolean(draftSettings.appUpdateBranch))
}
disabled={settingsLoading || (!isElectron && !userProfile)}
loading={saving}
/>
</Flex>
</div>
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
<Spin spinning={settingsLoading} indicator={<LoadingOutlined />}>
<Flex vertical gap='large'>
<InfoCollapse
title='Appearance Settings'
icon={<SettingOutlined />}
active={collapseState.appearance}
onToggle={(expanded) =>
updateCollapseState('appearance', expanded)
}
collapseKey='appearance'
>
<Descriptions
bordered
column={{
xs: 1,
sm: 1,
md: 1,
lg: 2,
xl: 2,
xxl: 2
}}
>
<Descriptions.Item label='Theme'>
{isEditing ? (
<Select
value={draftSettings.theme}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
theme: value
}))
}
style={{ width: '100%' }}
>
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
) : (
<Text>{currentThemeValue}</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
{isEditing ? (
<Select
value={draftSettings.density}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
density: value
}))
}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
) : (
<Text>{isCompact ? 'Compact' : 'Comfortable'}</Text>
)}
</Descriptions.Item>
</Descriptions>
</InfoCollapse>
{isElectron && (
<InfoCollapse
title='App Update Settings'
icon={<SettingOutlined />}
active={collapseState.appUpdates}
onToggle={(expanded) =>
updateCollapseState('appUpdates', expanded)
}
collapseKey='appUpdates'
>
<Descriptions bordered column={1}>
<Descriptions.Item label='Branch'>
{isEditing ? (
<Select
value={draftSettings.appUpdateBranch}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
appUpdateBranch: value
}))
}
style={{ width: '100%' }}
loading={branchLoading}
placeholder='Select a branch'
>
{branchOptions}
</Select>
) : (
<Text>{currentBranch}</Text>
)}
</Descriptions.Item>
</Descriptions>
</InfoCollapse>
)}
</Flex>
</Spin>
</div>
</Flex>
)
}

View File

@ -0,0 +1,300 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState
} from 'react'
import PropTypes from 'prop-types'
import { Button, Modal, Space, Typography } 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'
const { Text } = Typography
const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000
const DEFAULT_UPDATE_BRANCH = 'main'
const CURRENT_BUILD_NUMBER = import.meta.env.VITE_BUILD_NUMBER
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, startAppUpdate, onAppUpdateProgress } =
useContext(ElectronContext)
const [checking, setChecking] = useState(false)
const [noUpdateOpen, setNoUpdateOpen] = useState(false)
const [availableUpdate, setAvailableUpdate] = useState(null)
const [installingUpdate, setInstallingUpdate] = useState(null)
const [updateProgress, setUpdateProgress] = useState(null)
const runningCheckRef = useRef(null)
const updateCheckDependenciesRef = useRef({})
updateCheckDependenciesRef.current = {
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
getAppSettings,
isElectron,
token
}
const checkForAvailableUpdate = useCallback(async () => {
const {
fetchAppUpdateBranches,
fetchAppUpdateCurrent,
getAppSettings,
isElectron,
token
} = updateCheckDependenciesRef.current
if (!isElectron || !token) return null
if (runningCheckRef.current) return runningCheckRef.current
const checkPromise = (async () => {
const [branches, appSettings] = await Promise.all([
fetchAppUpdateBranches(),
getAppSettings()
])
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 update = await fetchAppUpdateCurrent(selectedBranch)
return isAppUpdateAvailable(update, appVersion, CURRENT_BUILD_NUMBER)
? update
: null
})()
runningCheckRef.current = checkPromise
try {
const update = await checkPromise
return update
} finally {
runningCheckRef.current = null
}
}, [])
const showUpdateIfAvailable = useCallback(async () => {
const update = await checkForAvailableUpdate()
if (update) {
setNoUpdateOpen(false)
setAvailableUpdate(update)
}
return update
}, [checkForAvailableUpdate])
const checkForUpdates = useCallback(async () => {
if (!isElectron) return null
setChecking(true)
try {
const update = await showUpdateIfAvailable()
if (!update) {
setNoUpdateOpen(true)
}
return update
} finally {
setChecking(false)
}
}, [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 || !onAppUpdateProgress) return undefined
return onAppUpdateProgress((progress) => {
setUpdateProgress(progress)
})
}, [isElectron, onAppUpdateProgress])
const closeUpdateModal = () => {
setAvailableUpdate(null)
setInstallingUpdate(null)
setUpdateProgress(null)
}
const handleUpdate = async (update) => {
setNoUpdateOpen(false)
setAvailableUpdate(null)
setInstallingUpdate(update)
setUpdateProgress({
phase: 'preparing',
percent: 0,
message: 'Preparing update'
})
try {
const result = await startAppUpdate(update)
if (!result) {
throw new Error('App updates are only available in the desktop app.')
}
} catch (error) {
setUpdateProgress({
phase: 'error',
percent: null,
message: error?.message || 'Failed to start the app update.'
})
}
}
const updateModalOpen = Boolean(availableUpdate || installingUpdate)
const updateModalBusy =
Boolean(installingUpdate) && updateProgress?.phase !== 'error'
return (
<AppUpdateContext.Provider value={{ checkForUpdates }}>
{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='Software Update'
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={installingUpdate ? 'Software Update' : 'Software Update Available'}
open={updateModalOpen}
footer={null}
width={650}
centered
closable={!updateModalBusy}
maskClosable={!updateModalBusy}
onCancel={updateModalBusy ? undefined : closeUpdateModal}
>
{installingUpdate ? (
<AppUpdateProgress
progress={updateProgress}
update={installingUpdate}
/>
) : (
<NewAppUpdate
update={availableUpdate}
onCancel={closeUpdateModal}
onUpdate={handleUpdate}
/>
)}
</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 }

View File

@ -120,6 +120,46 @@ const ElectronProvider = ({ children }) => {
return await ipcRenderer.invoke('auth-session-clear')
}
const getAppSettings = useCallback(async () => {
if (!electronAvailable || !ipcRenderer) return {}
return await ipcRenderer.invoke('app-settings-get')
}, [electronAvailable])
const setAppSettings = useCallback(
async (settings) => {
if (!electronAvailable || !ipcRenderer) return false
return await ipcRenderer.invoke('app-settings-set', settings)
},
[electronAvailable]
)
const startAppUpdate = useCallback(
async (update) => {
if (!electronAvailable || !ipcRenderer) return false
return await ipcRenderer.invoke('app-update-start', update)
},
[electronAvailable]
)
const onAppUpdateProgress = useCallback(
(handler) => {
if (!electronAvailable || !ipcRenderer || typeof handler !== 'function') {
return () => {}
}
const progressHandler = (event, progress) => {
handler(progress)
}
ipcRenderer.on('app-update-progress', progressHandler)
return () => {
ipcRenderer.removeListener('app-update-progress', progressHandler)
}
},
[electronAvailable]
)
// Backwards-compatible helpers
const getToken = async () => {
const session = await getAuthSession()
@ -170,6 +210,10 @@ const ElectronProvider = ({ children }) => {
getAuthSession,
setAuthSession,
clearAuthSession,
getAppSettings,
setAppSettings,
startAppUpdate,
onAppUpdateProgress,
getToken,
setToken,
resizeSpotlightWindow,

View File

@ -1,4 +1,10 @@
import { createContext, useContext, useState, useEffect } from 'react'
import {
createContext,
useCallback,
useContext,
useState,
useEffect
} from 'react'
import { theme } from 'antd'
import PropTypes from 'prop-types'
@ -86,6 +92,21 @@ export const ThemeProvider = ({ children }) => {
setIsCompact(!isCompact)
}
const setThemeMode = useCallback((value) => {
if (value === 'system') {
setIsSystem(true)
setIsDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches)
return
}
setIsSystem(false)
setIsDarkMode(value === 'dark')
}, [])
const setDensityMode = useCallback((value) => {
setIsCompact(value === 'compact')
}, [])
const getThemeAlgorithm = () => {
var baseAlgorithm
if (isDarkMode == true) {
@ -142,6 +163,8 @@ export const ThemeProvider = ({ children }) => {
toggleCompact,
isSystem,
toggleSystem,
setThemeMode,
setDensityMode,
getColors,
themeConfig
}}

View File

@ -143,12 +143,6 @@ const managementSidebarItems = [
label: 'Settings',
path: '/dashboard/management/settings'
},
{
key: 'appUpdate',
iconKey: 'settings',
label: 'App Update',
path: '/dashboard/management/appupdate'
},
{
key: 'files',
iconKey: 'file',

View File

@ -70,9 +70,6 @@ const CourierServiceInfo = lazy(
const Settings = lazy(
() => import('../components/Dashboard/Management/Settings')
)
const AppUpdate = lazy(
() => import('../components/Dashboard/Management/AppUpdate')
)
const AuditLogs = lazy(
() => import('../components/Dashboard/Management/AuditLogs.jsx')
)
@ -315,7 +312,6 @@ const ManagementRoutes = [
element={<AppPasswordInfo />}
/>,
<Route key='settings' path='management/settings' element={<Settings />} />,
<Route key='appupdate' path='management/appupdate' element={<AppUpdate />} />,
<Route key='about' path='management/about' element={<About />} />,
<Route key='auditlogs' path='management/auditlogs' element={<AuditLogs />} />,
<Route key='taxrates' path='management/taxrates' element={<TaxRates />} />,