diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index fe97119..4772b10 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -28,6 +28,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat'; +import { WebAppVersionCell } from '@/features/webapps/WebAppVersionCell'; import { DropdownMenu, DropdownMenuTrigger, @@ -354,6 +355,16 @@ export function DynamicList({ viewName }: DynamicListProps) { return { obj, schema: schem, list }; }, [schema, viewName]); + // The Web Applications list gets an extra client-side column showing the + // installed version of the WebUI app (and an update action when a newer + // release exists), which the server does not expose as an object property. + const isAppList = viewName === 'x:Application'; + const displayColumns = useMemo(() => { + const columns = resolved?.list?.columns ?? []; + if (!isAppList) return columns; + return [...columns, { name: 'x:webuiVersion', label: t('webApps.version', 'Version') }]; + }, [resolved?.list?.columns, isAppList, t]); + const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(null); const [loading, setLoading] = useState(false); @@ -420,7 +431,7 @@ export function DynamicList({ viewName }: DynamicListProps) { try { const accountId = getAccountId(obj.objectName); - const properties = ['id', ...list.columns.map((c) => c.name)]; + const properties = ['id', ...list.columns.map((c) => c.name), ...(isAppList ? ['resourceUrl'] : [])]; const filter = buildFilter(); const sortArr = buildSort(); @@ -470,7 +481,7 @@ export function DynamicList({ viewName }: DynamicListProps) { setLoading(false); } }, - [resolved, schema, buildFilter, buildSort, t], + [resolved, schema, buildFilter, buildSort, isAppList, t], ); useEffect(() => { @@ -1228,7 +1239,7 @@ export function DynamicList({ viewName }: DynamicListProps) { /> )} - {list.columns.map((col) => ( + {displayColumns.map((col) => (
{col.label} @@ -1247,7 +1258,7 @@ export function DynamicList({ viewName }: DynamicListProps) { {loading && items.length === 0 ? ( @@ -1256,7 +1267,7 @@ export function DynamicList({ viewName }: DynamicListProps) { ) : items.length === 0 ? ( {t('list.noResults', 'No results found')} @@ -1280,15 +1291,19 @@ export function DynamicList({ viewName }: DynamicListProps) { /> )} - {list.columns.map((col) => ( + {displayColumns.map((col) => ( - {renderCellValue( - item[col.name], - fields[col.name], - col.name, - schema!, - resolved.obj.objectName, - getDisplayName, + {col.name === 'x:webuiVersion' ? ( + + ) : ( + renderCellValue( + item[col.name], + fields[col.name], + col.name, + schema!, + resolved.obj.objectName, + getDisplayName, + ) )} ))} diff --git a/src/features/webapps/WebAppVersionCell.tsx b/src/features/webapps/WebAppVersionCell.tsx new file mode 100644 index 0000000..9bb86f5 --- /dev/null +++ b/src/features/webapps/WebAppVersionCell.tsx @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowUpCircle, Loader2 } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { jmapSet, getAccountId } from '@/services/jmap/client'; +import { useToast } from '@/hooks/use-toast'; +import { isUpdateAvailable, normalizeVersion } from '@/lib/versionCompare'; +import type { JmapSetResponse } from '@/types/jmap'; + +const WEBUI_REPO = 'stalwartlabs/webui'; +const GITHUB_LATEST_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/releases\/latest\//; +const LATEST_CACHE_TTL_MS = 5 * 60 * 1000; + +// The GitHub API is rate-limited per IP, so latest-release lookups are shared +// and deduplicated across rows and remounts. +const latestReleaseCache = new Map>(); + +function fetchLatestTag(repo: string): Promise { + const cached = latestReleaseCache.get(repo); + if (cached) return cached; + const promise = (async () => { + try { + const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!response.ok) return null; + const data = (await response.json()) as { tag_name?: unknown }; + return typeof data.tag_name === 'string' ? data.tag_name : null; + } catch { + return null; + } finally { + setTimeout(() => latestReleaseCache.delete(repo), LATEST_CACHE_TTL_MS); + } + })(); + latestReleaseCache.set(repo, promise); + return promise; +} + +interface WebAppVersionCellProps { + resourceUrl: unknown; +} + +export function WebAppVersionCell({ resourceUrl }: WebAppVersionCellProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const repo = typeof resourceUrl === 'string' ? GITHUB_LATEST_RE.exec(resourceUrl)?.[1] : undefined; + // The running bundle's version is only meaningful for the WebUI application + // itself; other apps do not expose their installed version. + const currentVersion = repo === WEBUI_REPO ? __APP_VERSION__ : null; + const [latestVersion, setLatestVersion] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [updating, setUpdating] = useState(false); + + useEffect(() => { + if (!repo) return; + let cancelled = false; + fetchLatestTag(repo).then((tag) => { + if (!cancelled && tag) setLatestVersion(tag); + }); + return () => { + cancelled = true; + }; + }, [repo]); + + if (!repo || !currentVersion) { + return ; + } + + const updateAvailable = latestVersion !== null && isUpdateAvailable(latestVersion, currentVersion); + + async function handleUpdate() { + setUpdating(true); + try { + const accountId = getAccountId('x:Action'); + const responses = await jmapSet('x:Action', accountId, { + create: { 'action-0': { '@type': 'UpdateApps' } }, + }); + const result = responses[responses.length - 1][1] as unknown as JmapSetResponse; + if (result.created?.['action-0']) { + toast({ + title: t('webApps.updateStarted', 'Update started'), + description: t( + 'webApps.updateStartedBody', + 'The server is downloading the latest version. Reload the page in a few seconds to use it.', + ), + }); + } else { + const err = result.notCreated?.['action-0']; + throw new Error(err?.description ?? err?.type ?? t('webApps.updateRejected', 'The server rejected the update.')); + } + } catch (error) { + toast({ + variant: 'destructive', + title: t('webApps.updateFailed', 'Update failed'), + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setUpdating(false); + } + } + + return ( +
e.stopPropagation()}> + v{normalizeVersion(currentVersion)} + {latestVersion && !updateAvailable && ( + {t('webApps.upToDate', 'Up to date')} + )} + {updateAvailable && latestVersion && ( + <> + + + + + {t('webApps.confirmTitle', 'Update application?')} + + {t( + 'webApps.confirmBody', + 'The application will be updated from v{{from}} to v{{to}}. The server downloads the new package and starts serving it immediately.', + { from: normalizeVersion(currentVersion), to: normalizeVersion(latestVersion) }, + )} + + + + {t('common.cancel', 'Cancel')} + {t('webApps.confirm', 'Update')} + + + + + )} +
+ ); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index d5618d1..9e45cce 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -371,5 +371,17 @@ "failedToLoad": "Failed to load", "noGetResponse": "No get response", "objectNotFound": "Object not found" + }, + "webApps": { + "confirm": "Update", + "confirmBody": "The application will be updated from v{{from}} to v{{to}}. The server downloads the new package and starts serving it immediately.", + "confirmTitle": "Update application?", + "updateFailed": "Update failed", + "updateRejected": "The server rejected the update.", + "updateStarted": "Update started", + "updateStartedBody": "The server is downloading the latest version. Reload the page in a few seconds to use it.", + "updateTo": "Update to v{{version}}", + "upToDate": "Up to date", + "version": "Version" } } diff --git a/src/lib/versionCompare.test.ts b/src/lib/versionCompare.test.ts new file mode 100644 index 0000000..721805e --- /dev/null +++ b/src/lib/versionCompare.test.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { describe, expect, it } from 'vitest'; +import { compareVersions, isUpdateAvailable, normalizeVersion } from './versionCompare'; + +describe('normalizeVersion', () => { + it('strips a leading v prefix and whitespace', () => { + expect(normalizeVersion('v1.2.3')).toBe('1.2.3'); + expect(normalizeVersion('V2.0')).toBe('2.0'); + expect(normalizeVersion(' 1.0.6 ')).toBe('1.0.6'); + }); +}); + +describe('compareVersions', () => { + it('orders dotted numeric versions', () => { + expect(compareVersions('v1.0.7', '1.0.6')).toBeGreaterThan(0); + expect(compareVersions('1.0.6', 'v1.0.7')).toBeLessThan(0); + expect(compareVersions('1.2.0', '1.2')).toBe(0); + }); + + it('returns null for unparsable versions', () => { + expect(compareVersions('nightly', '1.0.6')).toBeNull(); + }); +}); + +describe('isUpdateAvailable', () => { + it('is false when latest equals current', () => { + expect(isUpdateAvailable('v1.0.6', '1.0.6')).toBe(false); + }); + + it('is true when latest is newer', () => { + expect(isUpdateAvailable('v1.1.0', '1.0.6')).toBe(true); + }); + + it('is false when latest is older', () => { + expect(isUpdateAvailable('v1.0.5', '1.0.6')).toBe(false); + }); + + it('falls back to string difference for unparsable tags', () => { + expect(isUpdateAvailable('nightly-2', '1.0.6')).toBe(true); + expect(isUpdateAvailable('v1.0.6', '1.0.6-beta')).toBe(true); + }); +}); diff --git a/src/lib/versionCompare.ts b/src/lib/versionCompare.ts new file mode 100644 index 0000000..ba24500 --- /dev/null +++ b/src/lib/versionCompare.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +export function normalizeVersion(version: string): string { + return version.trim().replace(/^v/i, ''); +} + +// Compares two dotted numeric versions; returns null when either is unparsable. +export function compareVersions(a: string, b: string): number | null { + const pa = normalizeVersion(a).split('.').map(Number); + const pb = normalizeVersion(b).split('.').map(Number); + if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null; + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +// Falls back to a string difference when the versions are not dotted numerics, +// so an unexpected tag format still surfaces as an available update. +export function isUpdateAvailable(latestVersion: string, currentVersion: string): boolean { + const cmp = compareVersions(latestVersion, currentVersion); + if (cmp === null) return normalizeVersion(latestVersion) !== normalizeVersion(currentVersion); + return cmp > 0; +}