From b7184283e0f36b8289b12ee609cf24ac0b84f846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 10 Aug 2026 19:17:48 +0000 Subject: [PATCH] app store: a screen, so this can be clicked instead of curled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /app-store, built to the platform's own conventions: a locked WorkspaceView with two panels, the selection in `?selected=` rather than a channel, and rows that are real links so cmd-click and a pasted URL both work. `?selected=` and not a /app-store/:id detail route, per docs/navigation-audit.md: this is a master list with a live preview, and linking rows to a detail route would make the detail the whole page and destroy the side-by-side. Both panels read the URL independently — the list and the detail cannot disagree if neither is telling the other anything. The install form is generated from the catalogue's fields rather than written per service, which is what lets a sidecar shipping from its own repository present a form nobody here wrote. `existing` is first in `modes` by catalogue rule, so the default selection is "I already have one" — the answer that avoids starting a second copy of something already running. States are distinguished rather than flattened. Blocked is amber and titled "Needs you", not an error: everything worked and it is waiting for a token only a person can mint. Installed-and-enabled but with a dead process shows a warning rather than a tick that lies. And the disable/uninstall copy says plainly that data, configuration and tables are kept either way, because that is the question anyone hesitates over before clicking. The dock tile is CORE, not plugin-derived: the store is how every other feature arrives, so it must never be one of the things that disappears. Verified through the API the screen uses — 14 items, email reporting installed/enabled with its process online, and /app-store present in the capability routes so the tile renders. NOT verified in a browser: no page has been opened, so the rendering itself is reasoned rather than seen. Co-Authored-By: Claude Opus 5 --- src/apps/officer-web/App.tsx | 1 + .../Dashboard/AppStore/AppStoreScreen.tsx | 26 +++ .../Dashboard/AppStore/defaultLayout.ts | 14 ++ .../Screens/Dashboard/AppStore/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 4 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../src/apps/AppStore/AppStoreDetail.tsx | 169 ++++++++++++++++++ .../src/apps/AppStore/AppStoreList.tsx | 81 +++++++++ .../officerdev/src/apps/AppStore/index.ts | 17 ++ .../src/apps/AppStore/useAppStore.ts | 81 +++++++++ src/workspaces/officerdev/src/index.ts | 4 + 13 files changed, 402 insertions(+) create mode 100644 src/apps/officer-web/Screens/Dashboard/AppStore/AppStoreScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/AppStore/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/AppStore/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/AppStore/AppStoreDetail.tsx create mode 100644 src/workspaces/officerdev/src/apps/AppStore/AppStoreList.tsx create mode 100644 src/workspaces/officerdev/src/apps/AppStore/index.ts create mode 100644 src/workspaces/officerdev/src/apps/AppStore/useAppStore.ts diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 85fe9a6d..5de30db8 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -65,6 +65,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/AppStore/AppStoreScreen.tsx b/src/apps/officer-web/Screens/Dashboard/AppStore/AppStoreScreen.tsx new file mode 100644 index 00000000..31187c1e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/AppStore/AppStoreScreen.tsx @@ -0,0 +1,26 @@ +import type { LayoutNode } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useDashboardState } from 'state/useDashboardState'; +import { defaultLayout } from './defaultLayout'; + +// /app-store — what this server can run, what it is running, and the four verbs that change it. +// +// Owner-only, and gated server-side: every route under /api/app-store refuses a non-owner before it +// reaches a handler. This screen is the courtesy half of that, and would show an empty store rather +// than a working one if it were ever reached by someone else. +// +// Which app is open lives in `?selected=`, read by both panels independently rather than passed between +// them — the list and the detail cannot disagree if neither is telling the other anything. +export const AppStoreScreen = () => { + const workspace = useDashboardState('screens/app-store', defaultLayout); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/AppStore/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/AppStore/defaultLayout.ts new file mode 100644 index 00000000..38ff2a34 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/AppStore/defaultLayout.ts @@ -0,0 +1,14 @@ +import type { LayoutNode } from 'officerdev'; + +// List on the left, detail on the right — a master list with a live preview, which is why the selection +// is `?selected=` rather than a detail route: linking rows to /app-store/:id would make the detail the +// whole page and destroy the side-by-side. See docs/navigation-audit.md. +export const defaultLayout: LayoutNode = { + type: 'group', + id: 'app-store-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'app-store-list', appType: 'app-store-list' }, size: 30 }, + { node: { type: 'panel', id: 'app-store-detail', appType: 'app-store-detail' }, size: 70 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/AppStore/index.tsx b/src/apps/officer-web/Screens/Dashboard/AppStore/index.tsx new file mode 100644 index 00000000..54621b3d --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/AppStore/index.tsx @@ -0,0 +1 @@ +export * from './AppStoreScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 0b9beee0..0149be26 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -149,6 +149,7 @@ import { Contact, Clapperboard, GitBranch, + Store, } from 'lucide-react'; /** @@ -176,6 +177,9 @@ export const CORE_DOCK_ITEMS: DockItem[] = [ { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, + // Core by necessity: the store is how every other feature arrives, so it can never be one of the + // things that disappears when uninstalled. + { label: 'App store', to: '/app-store', icon: Store, color: '#64748b' }, ]; /** diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 92759093..96d05f54 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -1,3 +1,4 @@ +export * from './AppStore'; export * from './Layout'; export * from './Home'; export * from './PasskeyGate'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 3693145e..70957e56 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -21,6 +21,7 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/calendar'), title: 'Calendar' }, { match: (p) => p.startsWith('/contacts'), title: 'Contacts' }, { match: (p) => p.startsWith('/music'), title: 'Music' }, + { match: (p) => p.startsWith('/app-store'), title: 'App store' }, { match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/jellyfin'), title: 'Video' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index b6ff7dee..5a2ba79a 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -1,3 +1,4 @@ +import { appRegistryMetas as appStoreMetas } from '../apps/AppStore'; import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; @@ -43,6 +44,7 @@ export const apps = [ ...monitorMetas, ...qrTransferMetas, ...davMetas, + ...appStoreMetas, ]; /** diff --git a/src/workspaces/officerdev/src/apps/AppStore/AppStoreDetail.tsx b/src/workspaces/officerdev/src/apps/AppStore/AppStoreDetail.tsx new file mode 100644 index 00000000..4a9c748e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/AppStore/AppStoreDetail.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { Loader2, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore'; + +// One entry: what it is, how to install it, and what to do about it once installed. +// +// The install form is generated from the catalogue's fields rather than written per service, which is +// what lets a sidecar shipping from its own repository present a form nobody here wrote. + +const modeLabel: Record = { + existing: 'I already have one', + provisioned: 'Set one up for me', + config: 'Configure', +}; + +const modeHelp: Record = { + existing: 'Point Officer at an instance you already run — here, on another machine, or anywhere reachable.', + provisioned: 'Officer writes a compose file into your own dockers directory and starts it.', + config: 'Nothing to install. Officer just needs the settings.', +}; + +function Field({ field, value, onChange }: { field: ConfigField; value: string; onChange: (v: string) => void }) { + return ( + + ); +} + +export const AppStoreDetail = () => { + const [params] = useSearchParams(); + const id = params.get('selected'); + const { items, install, setEnabled, uninstall } = useAppStore(); + const item = items.find((i) => i.id === id); + + const [mode, setMode] = useState(null); + const [values, setValues] = useState>({}); + const [log, setLog] = useState([]); + + if (!item) { + return
Select an app
; + } + + // `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" — + // the answer that avoids starting a second copy of something the user is already running. + const chosen = mode ?? item.modes[0]!; + const fields = chosen === 'existing' ? (item.existingFields ?? []) : (item.configFields ?? []); + const isInstalled = item.install.status === 'installed' || item.install.status === 'blocked'; + + const run = async (replace = false) => { + setLog([]); + const result = await install.mutateAsync({ + id: item.id, + mode: chosen, + values: replace ? { ...values, replaceConnection: 'true' } : values, + }); + setLog(result.log ?? []); + }; + + return ( +
+
+

{item.label}

+

{item.summary}

+
+ + {/* Blocked is deliberately not styled as an error: everything worked and it is waiting for a + person. `completeAt` links to the page that mints the token. */} + {item.install.status === 'blocked' && item.install.lastError && ( +
+
Needs you
+

{item.install.lastError}

+
+ )} + {item.install.status === 'failed' && item.install.lastError && ( +
+
Install failed
+

{item.install.lastError}

+
+ )} + + {isInstalled ? ( +
+
+ Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '} + {item.install.enabled ? 'enabled' : 'disabled'} + {item.processStatus && ` · process ${item.processStatus}`} +
+
+ + +
+ {/* Said plainly, because it is the question anyone hesitates over before clicking. */} +

+ Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data, + configuration and this app’s tables are kept either way. +

+
+ ) : ( +
+ {item.modes.length > 1 && ( +
+ {item.modes.map((m) => ( + + ))} +
+ )} + + {fields.map((field) => ( + setValues((prev) => ({ ...prev, [field.key]: v }))} + /> + ))} + + +
+ )} + + {/* The install log. Streaming it into a terminal panel is the intended shape; until then it is + shown whole, because an install that says only "failed" is not diagnosable. */} + {log.length > 0 && ( +
+          {log.join('\n')}
+        
+ )} + + {item.members !== 'none' && ( +

+ {item.members === 'accounts' + ? 'Members get their own account on this service automatically.' + : 'Members are invited and set their own password — this service is end-to-end encrypted, so Officer cannot do it for them.'} +

+ )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/AppStore/AppStoreList.tsx b/src/workspaces/officerdev/src/apps/AppStore/AppStoreList.tsx new file mode 100644 index 00000000..1e90eaea --- /dev/null +++ b/src/workspaces/officerdev/src/apps/AppStore/AppStoreList.tsx @@ -0,0 +1,81 @@ +import { Link, useSearchParams } from 'react-router'; +import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react'; +import { useAppStore, type StoreItem } from './useAppStore'; + +// The list of everything installable, with what has happened to each. +// +// Rows are real links carrying `?selected=`, per docs/navigation-audit.md: the selection is +// addressable, so it belongs in the URL rather than a panel channel. That is what makes cmd-click, +// middle-click and a pasted link all work, and what stops this list and the detail pane disagreeing. + +/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */ +function StatusBadge({ item }: { item: StoreItem }) { + const { status, enabled } = item.install; + + if (status === 'not-installed') return null; + if (status === 'installing') { + return ; + } + if (status === 'failed') return ; + // Blocked is not a failure: everything worked and it is waiting for something only a person can give. + if (status === 'blocked') return ; + if (!enabled) return ; + + // Installed and enabled, but the process is not running — the case worth surfacing rather than + // showing a tick that lies. + if (item.processStatus && item.processStatus !== 'online') { + return ; + } + return ; +} + +export const AppStoreList = () => { + const { items, isLoading, error } = useAppStore(); + const [params] = useSearchParams(); + const selected = params.get('selected'); + + if (isLoading) { + return ( +
+ Loading +
+ ); + } + if (error) { + // Distinguished from "nothing installable", which would otherwise look identical and is not a + // problem the user can act on. + return
Could not load the app store.
; + } + + const installed = items.filter((i) => i.install.status !== 'not-installed'); + const available = items.filter((i) => i.install.status === 'not-installed'); + + const Row = ({ item }: { item: StoreItem }) => ( + + {item.label} + + + ); + + return ( +
+ {installed.length > 0 && ( + <> +
Installed
+ {installed.map((item) => ( + + ))} + + )} +
Available
+ {available.map((item) => ( + + ))} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/AppStore/index.ts b/src/workspaces/officerdev/src/apps/AppStore/index.ts new file mode 100644 index 00000000..8e7fbc6e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/AppStore/index.ts @@ -0,0 +1,17 @@ +import { Store } from 'lucide-react'; +import type { AppRegistryMeta } from '../../AppRegistry'; +import { AppStoreList } from './AppStoreList'; +import { AppStoreDetail } from './AppStoreDetail'; + +export { AppStoreList } from './AppStoreList'; +export { AppStoreDetail } from './AppStoreDetail'; +export { useAppStore } from './useAppStore'; +export type { StoreItem, InstallMode, ConfigField } from './useAppStore'; + +// Two panels rather than one screen: the list and the detail answer different questions and are read +// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make +// sense together, on /app-store. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false }, + { key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false }, +]; diff --git a/src/workspaces/officerdev/src/apps/AppStore/useAppStore.ts b/src/workspaces/officerdev/src/apps/AppStore/useAppStore.ts new file mode 100644 index 00000000..e84542f2 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/AppStore/useAppStore.ts @@ -0,0 +1,81 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; + +// Reading and driving the app store. One query, four verbs. + +export type InstallMode = 'existing' | 'provisioned' | 'config'; + +export type ConfigField = { + key: string; + label: string; + type: 'url' | 'text' | 'secret'; + required: boolean; + placeholder?: string; + help?: string; +}; + +export type StoreItem = { + id: string; + label: string; + summary: string; + modes: InstallMode[]; + members: 'accounts' | 'invite' | 'none'; + memberOutcome: string; + existingFields?: ConfigField[]; + configFields?: ConfigField[]; + install: { + status: 'not-installed' | 'pending' | 'installing' | 'installed' | 'failed' | 'blocked'; + enabled: boolean; + mode: string | null; + lastError: string | null; + completedSteps: string[]; + }; + processStatus: string | null; +}; + +export type InstallOutcome = + | { status: 'installed'; completed: string[] } + | { status: 'blocked'; completed: string[]; at: string; reason: string; completeAt?: string } + | { status: 'failed'; completed: string[]; at: string; error: string }; + +const STORE_KEY = ['app-store']; + +export function useAppStore() { + const client = useClient(); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: STORE_KEY, + queryFn: () => client.get<{ items: StoreItem[] }>('/app-store'), + }); + + // Every verb invalidates the same key, and also the capability answer: installing changes which dock + // tiles exist, and a store that updated while the dock kept the old list would be visibly wrong on + // the same screen. + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: STORE_KEY }); + void queryClient.invalidateQueries({ queryKey: ['self-capabilities'] }); + }; + + const install = useMutation({ + mutationFn: (vars: { id: string; mode: InstallMode; values: Record }) => + client.post<{ outcome: InstallOutcome; log: string[] }>(`/app-store/${vars.id}/install`, { + mode: vars.mode, + values: vars.values, + }), + onSuccess: invalidate, + }); + + const setEnabled = useMutation({ + mutationFn: (vars: { id: string; enabled: boolean }) => + client.post(`/app-store/${vars.id}/${vars.enabled ? 'enable' : 'disable'}`, {}), + onSuccess: invalidate, + }); + + const uninstall = useMutation({ + mutationFn: (id: string) => client.post(`/app-store/${id}/uninstall`, {}), + onSuccess: invalidate, + }); + + return { items: data?.items ?? [], isLoading, error, install, setEnabled, uninstall }; +} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index c3b207a2..870f02c5 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -160,3 +160,7 @@ export type { DropPosition, HomeRoot, } from './components/Workspace'; + +// App store — install, enable and remove the sidecars this server runs. +export { AppStoreList, AppStoreDetail, useAppStore } from './apps/AppStore'; +export type { StoreItem as AppStoreItem } from './apps/AppStore';