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';