From 786b7a3b5f1293054bf916f05ad03d9bb7b1173e Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:40:05 +0100 Subject: [PATCH] [feat](maestro): publish an operator-authored app catalog An instance distributes installable Android apps independently of the services it runs. Add /catalog/apps.json (owner-authored, atomic, 0600) with hosted-or-linked artifacts, SHA-256 and optional icon; expose the owner-gated add/edit/delete/replace API plus session-free artifact and icon downloads, and add the apps section to the public profile. The catalog is deliberately independent of services and the registry, and registry listings gain the declared service identity and a validated source href so cards can be paired correctly. --- .../settings/app-catalog-section.tsx | 1150 +++++++++++++++++ .../components/settings/settings-dialog.tsx | 32 +- src/frontend/src/lib/api/catalog.ts | 162 +++ src/frontend/src/lib/api/client.ts | 17 +- src/frontend/src/lib/hooks/useCatalogApps.ts | 190 +++ src/frontend/src/lib/types.ts | 149 ++- src/handlers/catalog/catalog.go | 749 +++++++++++ src/handlers/catalog/catalog_test.go | 515 ++++++++ src/handlers/catalog/public.go | 147 +++ src/handlers/catalog/public_test.go | 156 +++ src/handlers/instance/public.go | 62 +- src/handlers/instance/public_test.go | 106 +- src/lib/catalog/catalog.go | 165 +++ src/lib/catalog/store.go | 368 ++++++ src/lib/catalog/store_test.go | 251 ++++ src/lib/registry/registry.go | 41 +- src/lib/registry/registry_test.go | 108 ++ src/lib/types/registry.go | 33 +- 18 files changed, 4307 insertions(+), 94 deletions(-) create mode 100644 src/frontend/src/components/settings/app-catalog-section.tsx create mode 100644 src/frontend/src/lib/api/catalog.ts create mode 100644 src/frontend/src/lib/hooks/useCatalogApps.ts create mode 100644 src/handlers/catalog/catalog.go create mode 100644 src/handlers/catalog/catalog_test.go create mode 100644 src/handlers/catalog/public.go create mode 100644 src/handlers/catalog/public_test.go create mode 100644 src/lib/catalog/catalog.go create mode 100644 src/lib/catalog/store.go create mode 100644 src/lib/catalog/store_test.go 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' && ( + + )} + + + +
+ +