diff --git a/src/frontend/src/components/settings/app-catalog-section.tsx b/src/frontend/src/components/settings/app-catalog-section.tsx new file mode 100644 index 0000000..dd7acd6 --- /dev/null +++ b/src/frontend/src/components/settings/app-catalog-section.tsx @@ -0,0 +1,1150 @@ +/** + * Copyright (C) 2026 Dcntrlized + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useId, useRef, useState } from 'preact/hooks' +import type { ComponentChildren } from 'preact' +import { + AlertCircle, + Copy, + Image as ImageIcon, + Link2, + LoaderCircle, + Package, + Pencil, + Plus, + Trash2, + Upload, +} from 'lucide-react' +import { Dialog, Modal, ModalOverlay } from 'react-aria-components' +import { toast } from 'sonner' +import { Button } from '@components/ui/button' +import { Input } from '@components/ui/input' +import { ApiError } from '@lib/api/client' +import { useAsyncAction } from '@lib/hooks/useAsyncAction' +import { useCatalogApps, type CatalogController } from '@lib/hooks/useCatalogApps' +import { cn } from '@lib/utils' +import type { + CatalogApp, + CatalogAppInput, + CatalogAppPatch, + CatalogArtifactKind, +} from '@lib/types' + +/* ------------------------------------------------------------------ helpers */ + +function formatBytes(bytes: number | undefined): string { + if (!bytes || bytes < 0) return '—' + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB', 'TB'] + let value = bytes / 1024 + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}` +} + +function formatDate(iso?: string | null): string { + if (!iso) return '—' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function initials(name: string): string { + const words = name.trim().split(/[\s._-]+/).filter(Boolean) + if (words.length === 0) return '?' + if (words.length === 1) return words[0].slice(0, 2).toUpperCase() + return (words[0][0] + words[1][0]).toUpperCase() +} + +/** Maps the frozen error envelope onto plain, recovery-naming copy. */ +function describeError(error: unknown, fallback: string): string { + const code = error instanceof ApiError ? error.code : undefined + const status = error instanceof ApiError ? error.status : 0 + + if (code === 'ERR_CHECKSUM_MISMATCH') { + return 'The artifact’s sha256 does not match the checksum you supplied. Nothing was stored.' + } + if (code === 'ERR_APP_EXISTS' || status === 409) { + return 'An app with that package is already in the catalog. Edit the existing entry instead.' + } + if (code === 'ERR_ARTIFACT_FETCH' || status === 502) { + return 'Could not download that artifact URL. Check the link is reachable, then try again.' + } + if (code === 'ERR_INVALID_PAYLOAD' || status === 400) { + return 'The server rejected one of the fields. Check the required values and try again.' + } + if (code === 'ERR_NOT_FOUND' || status === 404) { + return 'That app is no longer in the catalog. Reload the list.' + } + if (status === 401 || status === 428) return 'Your session ended. Sign in again.' + if (status === 0) return 'Could not reach this node’s API.' + return fallback +} + +/* -------------------------------------------------------------- small parts */ + +function Field({ + id, + label, + required, + hint, + error, + children, +}: { + id: string + label: string + required?: boolean + hint?: string + error?: string + children: ComponentChildren +}) { + return ( +
+ + {children} + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ) +} + +interface TextFieldProps { + id: string + label: string + value: string + onInput: (value: string) => void + required?: boolean + hint?: string + error?: string + placeholder?: string + type?: string + mono?: boolean + readOnly?: boolean + inputMode?: 'text' | 'numeric' | 'url' + maxLength?: number +} + +function TextField(props: TextFieldProps) { + const describedBy = props.error ? `${props.id}-error` : props.hint ? `${props.id}-hint` : undefined + return ( + + props.onInput((event.target as HTMLInputElement).value)} + /> + + ) +} + +function CopyChip({ value, label, display }: { value: string; label: string; display: string }) { + const copy = useAsyncAction(async () => { + try { + await navigator.clipboard.writeText(value) + toast.success('Copied', { description: label }) + } catch { + toast.error('Could not copy to the clipboard') + } + }) + return ( + + ) +} + +/** Icon-only row action with a native hover title (RAC's ButtonProps has no `title`). */ +function IconAction({ + label, + onPress, + isDisabled, + isPending, + children, +}: { + label: string + onPress: () => void + isDisabled?: boolean + isPending?: boolean + children: ComponentChildren +}) { + return ( + + + + ) +} + +const KIND_META: Record = { + hosted: { + label: 'Hosted', + chip: 'border-signal/25 bg-signal/10 text-signal', + icon: Upload, + }, + link: { + label: 'Link only', + chip: 'border-border bg-muted/60 text-foreground/80', + icon: Link2, + }, +} + +function KindChip({ kind }: { kind: CatalogArtifactKind }) { + const meta = KIND_META[kind] ?? KIND_META.hosted + const Icon = meta.icon + return ( + + + ) +} + +function AppIcon({ app }: { app: CatalogApp }) { + return ( +
+ {app.icon_url ? ( + + ) : ( + + )} +
+ ) +} + +/* --------------------------------------------------------------- app row */ + +function AppRow({ + app, + upload, + busy, + onEdit, + onDelete, + onUpload, + onCancelUpload, +}: { + app: CatalogApp + upload: { percent: number; label: string } | undefined + busy: boolean + onEdit: (app: CatalogApp) => void + onDelete: (app: CatalogApp) => void + onUpload: (app: CatalogApp, kind: 'artifact' | 'icon', file: File) => void + onCancelUpload: (id: string) => void +}) { + const artifactInput = useRef(null) + const iconInput = useRef(null) + + const copyUrl = useAsyncAction(async () => { + try { + await navigator.clipboard.writeText(app.artifact.download_url) + toast.success('Copied download URL', { description: app.name }) + } catch { + toast.error('Could not copy to the clipboard') + } + }) + + return ( +
+
+ +
+
+ + {app.name} + + + v{app.version_name} + + +
+

{app.package}

+ {app.summary && ( +

+ {app.summary} +

+ )} +
+
+ +
+ + +
+ +
+
+
Artifact size
+
{formatBytes(app.artifact.size_bytes)}
+
+
+
Added
+
Added {formatDate(app.added_at)}
+
+ {app.artifact.filename && ( +
+
Artifact file
+
{app.artifact.filename}
+
+ )} +
+ + {upload && ( +
+
+ + {upload.label}… {upload.percent}% + + +
+
+
+
+
+ )} + +
+ onEdit(app)} + isDisabled={busy} + > + + + artifactInput.current?.click()} + isDisabled={busy} + > + + { + const file = (event.target as HTMLInputElement).files?.[0] + if (file) onUpload(app, 'artifact', file) + ;(event.target as HTMLInputElement).value = '' + }} + /> + + iconInput.current?.click()} + isDisabled={busy} + > + + { + const file = (event.target as HTMLInputElement).files?.[0] + if (file) onUpload(app, 'icon', file) + ;(event.target as HTMLInputElement).value = '' + }} + /> + + void copyUrl.run()} + isPending={copyUrl.pending} + > + + + +
+
+ ) +} + +/* ------------------------------------------------------------ form dialog */ + +interface Draft { + package: string + name: string + versionName: string + artifactUrl: string + linkOnly: boolean + sha256: string + summary: string + description: string + category: string + versionCode: string + minAndroid: string + iconUrl: string + signerSha256: string + sourceUrl: string +} + +const EMPTY_DRAFT: Draft = { + package: '', + name: '', + versionName: '', + artifactUrl: '', + linkOnly: false, + sha256: '', + summary: '', + description: '', + category: '', + versionCode: '', + minAndroid: '', + iconUrl: '', + signerSha256: '', + sourceUrl: '', +} + +function draftFrom(app: CatalogApp): Draft { + return { + ...EMPTY_DRAFT, + package: app.package, + name: app.name, + versionName: app.version_name, + sha256: app.artifact.sha256, + summary: app.summary ?? '', + description: app.description ?? '', + category: app.category ?? '', + versionCode: app.version_code !== undefined ? String(app.version_code) : '', + minAndroid: app.min_android !== undefined ? String(app.min_android) : '', + iconUrl: app.icon_url ?? '', + signerSha256: app.artifact.signer_sha256 ?? '', + sourceUrl: app.source_url ?? '', + } +} + +const HEX64 = /^[0-9a-f]{64}$/i +const PACKAGE_ID = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/ +const URL_LIKE = /^https?:\/\/\S+$/i + +type Errors = Partial> + +function validate(draft: Draft, file: File | null, mode: 'add' | 'edit'): Errors { + const errors: Errors = {} + if (mode === 'add' && !PACKAGE_ID.test(draft.package.trim())) { + errors.package = 'Use a package id with at least two segments, like org.example.app.' + } + if (!draft.name.trim()) errors.name = 'Give the app a name clients will see.' + if (!draft.versionName.trim()) errors.versionName = 'A version name is required, like 1.4.2.' + + if (mode === 'add') { + const url = draft.artifactUrl.trim() + if (!file && !url) { + errors.artifactUrl = 'Give an artifact URL or choose an APK file.' + } else if (!file && url && !URL_LIKE.test(url)) { + errors.artifactUrl = 'Enter a full URL starting with http:// or https://.' + } + if (draft.linkOnly && !draft.sha256.trim()) { + errors.sha256 = 'Link-only entries need the artifact’s sha256 so clients can verify it.' + } + } + if (draft.sha256.trim() && !HEX64.test(draft.sha256.trim().replace(/[\s:]/g, ''))) { + errors.sha256 = 'A sha256 checksum is 64 hexadecimal characters.' + } + if (draft.versionCode.trim() && !/^\d+$/.test(draft.versionCode.trim())) { + errors.versionCode = 'A version code is a whole number.' + } + if (draft.minAndroid.trim() && !/^\d+$/.test(draft.minAndroid.trim())) { + errors.minAndroid = 'A minimum Android API level is a whole number.' + } + if (draft.signerSha256.trim() && !/^[0-9a-f:]{40,}$/i.test(draft.signerSha256.trim())) { + errors.signerSha256 = 'A signer fingerprint is a hex sha256, with or without colons.' + } + if (draft.iconUrl.trim() && !URL_LIKE.test(draft.iconUrl.trim())) { + errors.iconUrl = 'Enter a full URL starting with http:// or https://.' + } + if (draft.sourceUrl.trim() && !URL_LIKE.test(draft.sourceUrl.trim())) { + errors.sourceUrl = 'Enter a full URL starting with http:// or https://.' + } + return errors +} + +/** Flat snake_case metadata shared by the add and edit payloads. */ +function metadataPayload(draft: Draft) { + return { + name: draft.name.trim(), + version_name: draft.versionName.trim(), + summary: draft.summary.trim(), + description: draft.description.trim(), + category: draft.category.trim(), + icon_url: draft.iconUrl.trim(), + signer_sha256: draft.signerSha256.trim(), + source_url: draft.sourceUrl.trim(), + ...(draft.versionCode.trim() ? { version_code: Number(draft.versionCode) } : {}), + ...(draft.minAndroid.trim() ? { min_android: Number(draft.minAndroid) } : {}), + } +} + +function AppFormDialog({ + mode, + app, + controller, + onClose, +}: { + mode: 'add' | 'edit' + app?: CatalogApp + controller: CatalogController + onClose: () => void +}) { + const uid = useId() + const [draft, setDraft] = useState(() => (app ? draftFrom(app) : EMPTY_DRAFT)) + const [file, setFile] = useState(null) + const [errors, setErrors] = useState({}) + const [formError, setFormError] = useState(null) + const [busy, setBusy] = useState(false) + const [percent, setPercent] = useState(null) + const fileInput = useRef(null) + const formRef = useRef(null) + + const field = (key: keyof Draft) => ({ + value: draft[key] as string, + onInput: (value: string) => setDraft((prev) => ({ ...prev, [key]: value })), + error: errors[key], + }) + + async function submit() { + const nextErrors = validate(draft, file, mode) + setErrors(nextErrors) + setFormError(null) + const first = Object.keys(nextErrors)[0] + if (first) { + // Move the operator to the field that needs them, not just to the message. + const target = formRef.current?.querySelector(`#${uid}-${first}`) + target?.focus() + return + } + + setBusy(true) + setPercent(file ? 0 : null) + try { + if (mode === 'add') { + const input: CatalogAppInput = { + package: draft.package.trim(), + ...metadataPayload(draft), + ...(file ? {} : { artifact_url: draft.artifactUrl.trim() }), + ...(draft.linkOnly ? { link_only: true } : {}), + ...(draft.sha256.trim() + ? { sha256: draft.sha256.trim().replace(/[\s:]/g, '').toLowerCase() } + : {}), + } + await controller.add(input, file, (value) => setPercent(value)) + toast.success('App added to the catalog', { description: input.name }) + } else if (app) { + await controller.update(app.id, metadataPayload(draft) as CatalogAppPatch) + toast.success('Metadata saved', { description: draft.name.trim() }) + } + onClose() + } catch (error) { + setFormError(describeError(error, 'Could not save this app.')) + } finally { + setBusy(false) + setPercent(null) + } + } + + const title = mode === 'add' ? 'Add an app' : `Edit ${app?.name ?? 'app'}` + const description = + mode === 'add' + ? 'Publish an app so clients can discover and install it from this instance. The catalog is independent of the services this node runs.' + : 'Package ids are fixed once an app is published; everything else can change.' + + return ( + { + if (!open && !busy) onClose() + }} + isDismissable={!busy} + className="fixed inset-0 z-[60] grid place-items-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm" + > + + +
{ + event.preventDefault() + void submit() + }} + > +
+

{title}

+

{description}

+
+ + {formError && ( +

+

+ )} + +
+ + + + +
+ +
+ {mode === 'add' && ( + <> + + +
+ + { + const picked = (event.target as HTMLInputElement).files?.[0] ?? null + setFile(picked) + if (picked) { + setDraft((prev) => ({ ...prev, artifactUrl: '', linkOnly: false })) + } + }} + /> + + {file ? file.name : 'No file chosen — a URL is used instead.'} + +
+ + + + )} + + {mode === 'add' && ( + + )} + + + +
+ +