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 && (
+
+ {/* 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.
+
+ )}
+
+ {/* 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