Implemented software update installation.
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
This commit is contained in:
parent
ea57ba65f3
commit
78dc567a8f
@ -38,6 +38,7 @@
|
|||||||
"@tsparticles/react": "^3.0.0",
|
"@tsparticles/react": "^3.0.0",
|
||||||
"@tsparticles/slim": "^3.9.1",
|
"@tsparticles/slim": "^3.9.1",
|
||||||
"@uiw/react-codemirror": "^4.25.1",
|
"@uiw/react-codemirror": "^4.25.1",
|
||||||
|
"@vscode/sudo-prompt": "^9.3.2",
|
||||||
"antd": "^5.27.1",
|
"antd": "^5.27.1",
|
||||||
"antd-style": "^3.7.1",
|
"antd-style": "^3.7.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -95,6 +95,9 @@ importers:
|
|||||||
'@uiw/react-codemirror':
|
'@uiw/react-codemirror':
|
||||||
specifier: ^4.25.1
|
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)
|
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:
|
antd:
|
||||||
specifier: ^5.27.1
|
specifier: ^5.27.1
|
||||||
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 5.29.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@ -2125,6 +2128,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
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':
|
'@xmldom/xmldom@0.8.11':
|
||||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@ -8306,6 +8312,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@vscode/sudo-prompt@9.3.2': {}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.8.11': {}
|
'@xmldom/xmldom@0.8.11': {}
|
||||||
|
|
||||||
'@zeit/schemas@2.36.0': {}
|
'@zeit/schemas@2.36.0': {}
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { app, ipcMain, shell, globalShortcut, safeStorage } from 'electron'
|
import { app, ipcMain, shell, globalShortcut, safeStorage } from 'electron'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
import { Buffer } from 'buffer'
|
||||||
|
import process from 'process'
|
||||||
import {
|
import {
|
||||||
registerGlobalShortcuts,
|
registerGlobalShortcuts,
|
||||||
setupSpotlightIPC
|
setupSpotlightIPC
|
||||||
@ -12,12 +14,17 @@ import {
|
|||||||
setupSingleInstanceLock,
|
setupSingleInstanceLock,
|
||||||
handleDeepLinkFromArgv
|
handleDeepLinkFromArgv
|
||||||
} from './mainWindow.js'
|
} from './mainWindow.js'
|
||||||
|
import { setupAppUpdateIPC } from './appupdate.js'
|
||||||
|
|
||||||
// --- Auth session storage (main process) ---
|
// --- Auth session storage (main process) ---
|
||||||
const authStore = new Store({
|
const authStore = new Store({
|
||||||
name: 'auth-session'
|
name: 'auth-session'
|
||||||
})
|
})
|
||||||
const AUTH_SESSION_KEY = 'authSession'
|
const AUTH_SESSION_KEY = 'authSession'
|
||||||
|
const appSettingsStore = new Store({
|
||||||
|
name: 'settings'
|
||||||
|
})
|
||||||
|
const APP_SETTINGS_KEY = 'appSettings'
|
||||||
|
|
||||||
const serializeAuthSession = (session) => {
|
const serializeAuthSession = (session) => {
|
||||||
const sessionJson = JSON.stringify(session)
|
const sessionJson = JSON.stringify(session)
|
||||||
@ -76,6 +83,7 @@ if (gotTheLock) {
|
|||||||
registerGlobalShortcuts()
|
registerGlobalShortcuts()
|
||||||
setupSpotlightIPC()
|
setupSpotlightIPC()
|
||||||
setupMainWindowIPC()
|
setupMainWindowIPC()
|
||||||
|
setupAppUpdateIPC(app)
|
||||||
setupMainWindowAppEvents(app)
|
setupMainWindowAppEvents(app)
|
||||||
setupDevAuthServer()
|
setupDevAuthServer()
|
||||||
handleDeepLinkFromArgv()
|
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
|
// IPC handler for opening external URLs
|
||||||
ipcMain.handle('open-external-url', (event, url) => {
|
ipcMain.handle('open-external-url', (event, url) => {
|
||||||
shell.openExternal(url)
|
shell.openExternal(url)
|
||||||
|
|||||||
140
src/App.jsx
140
src/App.jsx
@ -28,6 +28,7 @@ import { ApiServerProvider } from './components/Dashboard/context/ApiServerConte
|
|||||||
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
|
import { NotificationProvider } from './components/Dashboard/context/NotificationContext.jsx'
|
||||||
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
|
import { ElectronProvider } from './components/Dashboard/context/ElectronContext.jsx'
|
||||||
import { MessageProvider } from './components/Dashboard/context/MessageContext.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 AuthCallback from './components/App/AuthCallback.jsx'
|
||||||
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
|
import EmailNotificationTemplate from './components/Email/EmailNotificationTemplate.jsx'
|
||||||
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
|
import MarketplaceAuthCallback from './components/Dashboard/Sales/Marketplaces/MarketplaceAuthCallback.jsx'
|
||||||
@ -76,73 +77,80 @@ const AppContent = () => {
|
|||||||
<PrintServerProvider>
|
<PrintServerProvider>
|
||||||
<ApiServerProvider>
|
<ApiServerProvider>
|
||||||
<MessageProvider>
|
<MessageProvider>
|
||||||
<NotificationProvider>
|
<AppUpdateProvider>
|
||||||
<SpotlightProvider>
|
<NotificationProvider>
|
||||||
<ActionsModalProvider>
|
<SpotlightProvider>
|
||||||
<Routes>
|
<ActionsModalProvider>
|
||||||
<Route path='/applaunch' element={<AuthLaunch />} />
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path='/dashboard/electron/spotlightcontent'
|
path='/applaunch'
|
||||||
element={
|
element={<AuthLaunch />}
|
||||||
<PrivateRoute
|
/>
|
||||||
component={() => (
|
<Route
|
||||||
<ElectronSpotlightContentPage />
|
path='/dashboard/electron/spotlightcontent'
|
||||||
)}
|
element={
|
||||||
/>
|
<PrivateRoute
|
||||||
}
|
component={() => (
|
||||||
/>
|
<ElectronSpotlightContentPage />
|
||||||
<Route
|
)}
|
||||||
path='/'
|
/>
|
||||||
element={
|
}
|
||||||
<PrivateRoute
|
/>
|
||||||
component={() => (
|
<Route
|
||||||
<Navigate
|
path='/'
|
||||||
to='/dashboard/production/overview'
|
element={
|
||||||
replace
|
<PrivateRoute
|
||||||
/>
|
component={() => (
|
||||||
)}
|
<Navigate
|
||||||
/>
|
to='/dashboard/production/overview'
|
||||||
}
|
replace
|
||||||
/>
|
/>
|
||||||
<Route
|
)}
|
||||||
path='/auth/callback'
|
/>
|
||||||
element={<AuthCallback />}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/auth/marketplace/callback'
|
path='/auth/callback'
|
||||||
element={<MarketplaceAuthCallback />}
|
element={<AuthCallback />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/email/notification'
|
path='/auth/marketplace/callback'
|
||||||
element={<EmailNotificationTemplate />}
|
element={<MarketplaceAuthCallback />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/email/notification'
|
||||||
|
element={<EmailNotificationTemplate />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path='/dashboard'
|
path='/dashboard'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute component={() => <Dashboard />} />
|
<PrivateRoute
|
||||||
}
|
component={() => <Dashboard />}
|
||||||
>
|
/>
|
||||||
{ProductionRoutes}
|
}
|
||||||
{InventoryRoutes}
|
>
|
||||||
{FinanceRoutes}
|
{ProductionRoutes}
|
||||||
{SalesRoutes}
|
{InventoryRoutes}
|
||||||
{ManagementRoutes}
|
{FinanceRoutes}
|
||||||
{DeveloperRoutes}
|
{SalesRoutes}
|
||||||
</Route>
|
{ManagementRoutes}
|
||||||
<Route
|
{DeveloperRoutes}
|
||||||
path='*'
|
</Route>
|
||||||
element={
|
<Route
|
||||||
<AppError
|
path='*'
|
||||||
message='Error 404. Page not found.'
|
element={
|
||||||
showRefresh={false}
|
<AppError
|
||||||
/>
|
message='Error 404. Page not found.'
|
||||||
}
|
showRefresh={false}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
}
|
||||||
</ActionsModalProvider>
|
/>
|
||||||
</SpotlightProvider>
|
</Routes>
|
||||||
</NotificationProvider>
|
</ActionsModalProvider>
|
||||||
|
</SpotlightProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</AppUpdateProvider>
|
||||||
</MessageProvider>
|
</MessageProvider>
|
||||||
</ApiServerProvider>
|
</ApiServerProvider>
|
||||||
</PrintServerProvider>
|
</PrintServerProvider>
|
||||||
|
|||||||
@ -12,11 +12,13 @@ import useCollapseState from '../hooks/useCollapseState'
|
|||||||
import InfoCollapse from '../common/InfoCollapse'
|
import InfoCollapse from '../common/InfoCollapse'
|
||||||
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
import InfoCircleIcon from '../../Icons/InfoCircleIcon'
|
||||||
import ReloadIcon from '../../Icons/ReloadIcon'
|
import ReloadIcon from '../../Icons/ReloadIcon'
|
||||||
|
import DownloadIcon from '../../Icons/DownloadIcon'
|
||||||
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
import DeveloperIcon from '../../Icons/DeveloperIcon'
|
||||||
import { version as appVersion } from '../../../../package.json'
|
import { version as appVersion } from '../../../../package.json'
|
||||||
import { ApiServerContext } from '../context/ApiServerContext'
|
import { ApiServerContext } from '../context/ApiServerContext'
|
||||||
import { AuthContext } from '../context/AuthContext'
|
import { AuthContext } from '../context/AuthContext'
|
||||||
import { ElectronContext } from '../context/ElectronContext'
|
import { ElectronContext } from '../context/ElectronContext'
|
||||||
|
import { AppUpdateContext } from '../context/AppUpdateContext'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
const { Title, Text, Link } = Typography
|
const { Title, Text, Link } = Typography
|
||||||
|
|
||||||
@ -25,25 +27,37 @@ const About = () => {
|
|||||||
updater: true
|
updater: true
|
||||||
})
|
})
|
||||||
const { token } = useContext(AuthContext)
|
const { token } = useContext(AuthContext)
|
||||||
const actions = [
|
const { fetchApiServerVersion, fetchWsServerVersion } =
|
||||||
{
|
useContext(ApiServerContext)
|
||||||
label: 'Check for Updates',
|
const { isElectron, getElectronVersion } = useContext(ElectronContext)
|
||||||
icon: <ReloadIcon />,
|
const { checkForUpdates } = useContext(AppUpdateContext)
|
||||||
onClick: () => {
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
console.log('Check for Updates')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const buildNumber = import.meta.env.VITE_BUILD_NUMBER
|
const buildNumber = import.meta.env.VITE_BUILD_NUMBER
|
||||||
? 'b' + import.meta.env.VITE_BUILD_NUMBER
|
? 'b' + import.meta.env.VITE_BUILD_NUMBER
|
||||||
: 'dev'
|
: 'dev'
|
||||||
const developmentMode = import.meta.env.MODE === 'development'
|
const developmentMode = import.meta.env.MODE === 'development'
|
||||||
|
|
||||||
const { fetchApiServerVersion, fetchWsServerVersion } =
|
const actions = [
|
||||||
useContext(ApiServerContext)
|
{
|
||||||
const { isElectron, getElectronVersion } = useContext(ElectronContext)
|
label: 'Reload Window',
|
||||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
icon: <ReloadIcon />,
|
||||||
|
onClick: () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isElectron) {
|
||||||
|
actions.unshift(
|
||||||
|
{
|
||||||
|
label: 'Check for Updates',
|
||||||
|
icon: <DownloadIcon />,
|
||||||
|
onClick: checkForUpdates
|
||||||
|
},
|
||||||
|
{ type: 'divider' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -1,121 +1,309 @@
|
|||||||
import { Select, Typography, Descriptions, Collapse, Flex } from 'antd'
|
import { useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { CaretLeftOutlined } from '@ant-design/icons'
|
import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd'
|
||||||
|
import { LoadingOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
import { useThemeContext } from '../context/ThemeContext'
|
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 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 { Option } = Select
|
||||||
|
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const {
|
const { isDarkMode, isCompact, isSystem, setThemeMode, setDensityMode } =
|
||||||
isDarkMode,
|
useThemeContext()
|
||||||
toggleTheme,
|
const { fetchAppUpdateBranches } = useContext(ApiServerContext)
|
||||||
isCompact,
|
const { isElectron, getAppSettings, setAppSettings } =
|
||||||
toggleCompact,
|
useContext(ElectronContext)
|
||||||
isSystem,
|
const { userProfile, setUserProfile } = useContext(AuthContext)
|
||||||
toggleSystem
|
const { showSuccess, showError } = useMessageContext()
|
||||||
} = useThemeContext()
|
|
||||||
const [collapseState, updateCollapseState] = useCollapseState('Settings', {
|
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) => {
|
useEffect(() => {
|
||||||
if (value === 'system') {
|
const loadSettings = async () => {
|
||||||
toggleSystem()
|
setSettingsLoading(true)
|
||||||
} else {
|
const storedSettings = isElectron
|
||||||
if (isSystem) {
|
? await getAppSettings()
|
||||||
toggleSystem()
|
: userProfile?.settings || {}
|
||||||
}
|
setAppSettingsState(storedSettings || {})
|
||||||
if (value === 'dark' && !isDarkMode) {
|
setDraftSettings(storedSettings || {})
|
||||||
toggleTheme()
|
setSettingsLoading(false)
|
||||||
} else if (value === 'light' && isDarkMode) {
|
|
||||||
toggleTheme()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handleCompactChange = (value) => {
|
loadSettings()
|
||||||
if (value === 'compact' && !isCompact) {
|
}, [getAppSettings, isElectron, userProfile?.settings])
|
||||||
toggleCompact()
|
|
||||||
} else if (value === 'comfortable' && isCompact) {
|
useEffect(() => {
|
||||||
toggleCompact()
|
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 = () => {
|
const getCurrentThemeValue = () => {
|
||||||
if (isSystem) return 'system'
|
if (isSystem) return 'system'
|
||||||
return isDarkMode ? 'dark' : 'light'
|
return isDarkMode ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const currentThemeValue = getCurrentThemeValue()
|
||||||
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}>
|
const currentDensityValue = isCompact ? 'compact' : 'comfortable'
|
||||||
<Flex vertical gap={'large'}>
|
const currentBranch =
|
||||||
<Collapse
|
appSettings.appUpdateBranch ||
|
||||||
ghost
|
(branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) ||
|
||||||
expandIconPosition='end'
|
branches[0] ||
|
||||||
activeKey={collapseState.appearance ? ['1'] : []}
|
'Not configured'
|
||||||
onChange={(keys) =>
|
|
||||||
updateCollapseState('appearance', keys.length > 0)
|
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' }}
|
setThemeMode(draftSettings.theme)
|
||||||
/>
|
setDensityMode(draftSettings.density)
|
||||||
)}
|
setAppSettingsState(nextSettings)
|
||||||
className='no-h-padding-collapse'
|
setDraftSettings(nextSettings)
|
||||||
>
|
setIsEditing(false)
|
||||||
<Collapse.Panel
|
showSuccess('Settings saved.')
|
||||||
header={
|
} finally {
|
||||||
<Flex
|
setSaving(false)
|
||||||
align='center'
|
}
|
||||||
justify='space-between'
|
}
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
return (
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Flex vertical gap='large' style={{ height: '100%', minHeight: 0 }}>
|
||||||
Appearance Settings
|
<Flex justify='space-between' align='center'>
|
||||||
</Title>
|
<Space size='small'>
|
||||||
</Flex>
|
<ViewButton
|
||||||
}
|
disabled={settingsLoading}
|
||||||
key='1'
|
items={viewItems}
|
||||||
>
|
visibleState={collapseState}
|
||||||
<Descriptions
|
updateVisibleState={updateCollapseState}
|
||||||
bordered
|
/>
|
||||||
column={{
|
</Space>
|
||||||
xs: 1,
|
<EditButtons
|
||||||
sm: 1,
|
isEditing={isEditing}
|
||||||
md: 1,
|
handleUpdate={handleSave}
|
||||||
lg: 2,
|
cancelEditing={cancelEditing}
|
||||||
xl: 2,
|
startEditing={startEditing}
|
||||||
xxl: 2
|
formValid={
|
||||||
}}
|
Boolean(draftSettings.theme && draftSettings.density) &&
|
||||||
>
|
(!isElectron || Boolean(draftSettings.appUpdateBranch))
|
||||||
<Descriptions.Item label='Theme'>
|
}
|
||||||
<Select
|
disabled={settingsLoading || (!isElectron && !userProfile)}
|
||||||
value={getCurrentThemeValue()}
|
loading={saving}
|
||||||
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>
|
|
||||||
</Flex>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
300
src/components/Dashboard/context/AppUpdateContext.jsx
Normal file
300
src/components/Dashboard/context/AppUpdateContext.jsx
Normal 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 }
|
||||||
@ -120,6 +120,46 @@ const ElectronProvider = ({ children }) => {
|
|||||||
return await ipcRenderer.invoke('auth-session-clear')
|
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
|
// Backwards-compatible helpers
|
||||||
const getToken = async () => {
|
const getToken = async () => {
|
||||||
const session = await getAuthSession()
|
const session = await getAuthSession()
|
||||||
@ -170,6 +210,10 @@ const ElectronProvider = ({ children }) => {
|
|||||||
getAuthSession,
|
getAuthSession,
|
||||||
setAuthSession,
|
setAuthSession,
|
||||||
clearAuthSession,
|
clearAuthSession,
|
||||||
|
getAppSettings,
|
||||||
|
setAppSettings,
|
||||||
|
startAppUpdate,
|
||||||
|
onAppUpdateProgress,
|
||||||
getToken,
|
getToken,
|
||||||
setToken,
|
setToken,
|
||||||
resizeSpotlightWindow,
|
resizeSpotlightWindow,
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect
|
||||||
|
} from 'react'
|
||||||
import { theme } from 'antd'
|
import { theme } from 'antd'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
@ -86,6 +92,21 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
setIsCompact(!isCompact)
|
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 = () => {
|
const getThemeAlgorithm = () => {
|
||||||
var baseAlgorithm
|
var baseAlgorithm
|
||||||
if (isDarkMode == true) {
|
if (isDarkMode == true) {
|
||||||
@ -142,6 +163,8 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
toggleCompact,
|
toggleCompact,
|
||||||
isSystem,
|
isSystem,
|
||||||
toggleSystem,
|
toggleSystem,
|
||||||
|
setThemeMode,
|
||||||
|
setDensityMode,
|
||||||
getColors,
|
getColors,
|
||||||
themeConfig
|
themeConfig
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -143,12 +143,6 @@ const managementSidebarItems = [
|
|||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
path: '/dashboard/management/settings'
|
path: '/dashboard/management/settings'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'appUpdate',
|
|
||||||
iconKey: 'settings',
|
|
||||||
label: 'App Update',
|
|
||||||
path: '/dashboard/management/appupdate'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'files',
|
key: 'files',
|
||||||
iconKey: 'file',
|
iconKey: 'file',
|
||||||
|
|||||||
@ -70,9 +70,6 @@ const CourierServiceInfo = lazy(
|
|||||||
const Settings = lazy(
|
const Settings = lazy(
|
||||||
() => import('../components/Dashboard/Management/Settings')
|
() => import('../components/Dashboard/Management/Settings')
|
||||||
)
|
)
|
||||||
const AppUpdate = lazy(
|
|
||||||
() => import('../components/Dashboard/Management/AppUpdate')
|
|
||||||
)
|
|
||||||
const AuditLogs = lazy(
|
const AuditLogs = lazy(
|
||||||
() => import('../components/Dashboard/Management/AuditLogs.jsx')
|
() => import('../components/Dashboard/Management/AuditLogs.jsx')
|
||||||
)
|
)
|
||||||
@ -315,7 +312,6 @@ const ManagementRoutes = [
|
|||||||
element={<AppPasswordInfo />}
|
element={<AppPasswordInfo />}
|
||||||
/>,
|
/>,
|
||||||
<Route key='settings' path='management/settings' element={<Settings />} />,
|
<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='about' path='management/about' element={<About />} />,
|
||||||
<Route key='auditlogs' path='management/auditlogs' element={<AuditLogs />} />,
|
<Route key='auditlogs' path='management/auditlogs' element={<AuditLogs />} />,
|
||||||
<Route key='taxrates' path='management/taxrates' element={<TaxRates />} />,
|
<Route key='taxrates' path='management/taxrates' element={<TaxRates />} />,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user