From 78dc567a8f076d829d38f393854388c767fe5cfd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 21 Jun 2026 15:18:04 +0100 Subject: [PATCH] Implemented software update installation. --- package.json | 1 + pnpm-lock.yaml | 8 + public/appupdate.js | 398 ++++++++++++++++++ public/electron.js | 29 ++ src/App.jsx | 140 +++--- src/components/Dashboard/Management/About.jsx | 40 +- .../Dashboard/Management/AppUpdate.jsx | 186 -------- .../AppUpdates/AppUpdateProgress.jsx | 97 +++++ .../Management/AppUpdates/NewAppUpdate.jsx | 74 ++++ .../Dashboard/Management/Settings.jsx | 384 ++++++++++++----- .../Dashboard/context/AppUpdateContext.jsx | 300 +++++++++++++ .../Dashboard/context/ElectronContext.jsx | 44 ++ .../Dashboard/context/ThemeContext.jsx | 25 +- src/database/sidebars/management.js | 6 - src/routes/ManagementRoutes.jsx | 4 - 15 files changed, 1362 insertions(+), 374 deletions(-) delete mode 100644 src/components/Dashboard/Management/AppUpdate.jsx create mode 100644 src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx create mode 100644 src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx create mode 100644 src/components/Dashboard/context/AppUpdateContext.jsx diff --git a/package.json b/package.json index 1a13c86..28b78c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f820c1..e141c01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {} diff --git a/public/appupdate.js b/public/appupdate.js index e69de29..b2a5dbe 100644 --- a/public/appupdate.js +++ b/public/appupdate.js @@ -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 + }) +} diff --git a/public/electron.js b/public/electron.js index 0384c5b..504be18 100644 --- a/public/electron.js +++ b/public/electron.js @@ -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) diff --git a/src/App.jsx b/src/App.jsx index c0784b2..f25235c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 = () => { - - - - - } /> - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - } - /> - } - /> - } - /> + + + + + + } + /> + ( + + )} + /> + } + /> + ( + + )} + /> + } + /> + } + /> + } + /> + } + /> - } /> - } - > - {ProductionRoutes} - {InventoryRoutes} - {FinanceRoutes} - {SalesRoutes} - {ManagementRoutes} - {DeveloperRoutes} - - - } - /> - - - - + } + /> + } + > + {ProductionRoutes} + {InventoryRoutes} + {FinanceRoutes} + {SalesRoutes} + {ManagementRoutes} + {DeveloperRoutes} + + + } + /> + + + + + diff --git a/src/components/Dashboard/Management/About.jsx b/src/components/Dashboard/Management/About.jsx index 723710c..fe1d8d6 100644 --- a/src/components/Dashboard/Management/About.jsx +++ b/src/components/Dashboard/Management/About.jsx @@ -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: , - 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: , + onClick: () => { + window.location.reload() + } + } + ] + + if (isElectron) { + actions.unshift( + { + label: 'Check for Updates', + icon: , + onClick: checkForUpdates + }, + { type: 'divider' } + ) + } useEffect(() => { if (token) { diff --git a/src/components/Dashboard/Management/AppUpdate.jsx b/src/components/Dashboard/Management/AppUpdate.jsx deleted file mode 100644 index b9fb4ca..0000000 --- a/src/components/Dashboard/Management/AppUpdate.jsx +++ /dev/null @@ -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) => ( - - )), - [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 ( -
- - updateCollapseState('updater', keys.length > 0)} - expandIcon={({ isActive }) => ( - - )} - className='no-h-padding-collapse' - > - - - Application Updater - - - } - key='1' - > - - - - - - - - - -
- {currentUpdate ? ( - - - {currentUpdate.branch || selectedBranch} - - - {currentUpdate.buildNumber || 'Unknown'} - - - {currentUpdate.buildSource || 'Unknown'} - - - {currentUpdate.buildResult || 'Unknown'} - - - {buildTimestamp} - - - {currentUpdate.buildUrl ? ( - - Open Jenkins Build - - ) : ( - No build URL available - )} - - - {Array.isArray(currentUpdate.artifacts) && - currentUpdate.artifacts.length > 0 ? ( - - {currentUpdate.artifacts.map((artifact) => ( - - {artifact.fileName || artifact.relativePath} - - ))} - - ) : ( - No artifacts published - )} - - - ) : ( - - )} -
- - - -
- ) -} - -export default AppUpdate diff --git a/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx new file mode 100644 index 0000000..35263eb --- /dev/null +++ b/src/components/Dashboard/Management/AppUpdates/AppUpdateProgress.jsx @@ -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 ( + + + Updating Farm Control to version{' '} + {update?.version ? `${update.version}` : 'unknown'} build{' '} + {update?.buildNumber ? `${update.buildNumber}` : 'unknown'} from branch{' '} + {update?.branch ? `${update.branch}` : 'unknown'}... + + + {isError ? ( + + ) : ( + } + message={ + isInstalling + ? 'The app will close while the installer runs, then reopen automatically.' + : `Downloading ${artifactName}` + } + /> + )} + + {!isInstalling && ( + + )} + + {downloaded && total && ( + + {downloaded} of {total} downloaded + + )} + + ) +} + +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 diff --git a/src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx b/src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx new file mode 100644 index 0000000..9548d88 --- /dev/null +++ b/src/components/Dashboard/Management/AppUpdates/NewAppUpdate.jsx @@ -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 ( + + + A new Farm Control update is available. Would you like to update now? + + + + Farm Control Logo + + + {'Farm Control UI'} + + + + Version:{' '} + + {update?.version ? `v${update.version}` : 'Unknown'} + + + + Build Number:{' '} + + {update?.buildNumber ? `b${update.buildNumber}` : 'Unknown'} + + + + Branch: {update?.branch || 'Unknown'} + + + + + + + + + Built at: + + + + + + + + + ) +} + +NewAppUpdate.propTypes = { + update: PropTypes.object, + onCancel: PropTypes.func.isRequired, + onUpdate: PropTypes.func.isRequired +} + +export default NewAppUpdate diff --git a/src/components/Dashboard/Management/Settings.jsx b/src/components/Dashboard/Management/Settings.jsx index 315905c..db9a01f 100644 --- a/src/components/Dashboard/Management/Settings.jsx +++ b/src/components/Dashboard/Management/Settings.jsx @@ -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) => ( + + )), + [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 ( -
- - - 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 }) => ( - - )} - className='no-h-padding-collapse' - > - - - Appearance Settings - - - } - key='1' - > - - - - - - - - - - + })) + } + + setThemeMode(draftSettings.theme) + setDensityMode(draftSettings.density) + setAppSettingsState(nextSettings) + setDraftSettings(nextSettings) + setIsEditing(false) + showSuccess('Settings saved.') + } finally { + setSaving(false) + } + } + + return ( + + + + + + -
+
+ }> + + } + active={collapseState.appearance} + onToggle={(expanded) => + updateCollapseState('appearance', expanded) + } + collapseKey='appearance' + > + + + {isEditing ? ( + + ) : ( + {currentThemeValue} + )} + + + {isEditing ? ( + + ) : ( + {isCompact ? 'Compact' : 'Comfortable'} + )} + + + + {isElectron && ( + } + active={collapseState.appUpdates} + onToggle={(expanded) => + updateCollapseState('appUpdates', expanded) + } + collapseKey='appUpdates' + > + + + {isEditing ? ( + + ) : ( + {currentBranch} + )} + + + + )} + + +
+ ) } diff --git a/src/components/Dashboard/context/AppUpdateContext.jsx b/src/components/Dashboard/context/AppUpdateContext.jsx new file mode 100644 index 0000000..bd84155 --- /dev/null +++ b/src/components/Dashboard/context/AppUpdateContext.jsx @@ -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 ( + + {children} + + + + Checking for updates... + + + setNoUpdateOpen(false)} + onCancel={() => setNoUpdateOpen(false)} + footer={[ + + ]} + > + There are no new software updates available. + + + {installingUpdate ? ( + + ) : ( + + )} + + + ) +} + +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 } diff --git a/src/components/Dashboard/context/ElectronContext.jsx b/src/components/Dashboard/context/ElectronContext.jsx index 31e7ff7..7ef3132 100644 --- a/src/components/Dashboard/context/ElectronContext.jsx +++ b/src/components/Dashboard/context/ElectronContext.jsx @@ -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, diff --git a/src/components/Dashboard/context/ThemeContext.jsx b/src/components/Dashboard/context/ThemeContext.jsx index 94b0f36..7fac467 100644 --- a/src/components/Dashboard/context/ThemeContext.jsx +++ b/src/components/Dashboard/context/ThemeContext.jsx @@ -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 }} diff --git a/src/database/sidebars/management.js b/src/database/sidebars/management.js index 0b53c3c..a877649 100644 --- a/src/database/sidebars/management.js +++ b/src/database/sidebars/management.js @@ -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', diff --git a/src/routes/ManagementRoutes.jsx b/src/routes/ManagementRoutes.jsx index e7c348a..043760c 100644 --- a/src/routes/ManagementRoutes.jsx +++ b/src/routes/ManagementRoutes.jsx @@ -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={} />, } />, - } />, } />, } />, } />,