a /plugins screen to install, enable, disable and uninstall

the management surface for what the last commit made possible. two panels either
side of a selection that lives in ?selected= and is read by both independently,
so neither can be telling the other something stale — rows are real Links, not
buttons holding the name in a closure.

the detail panel shows what the tree declared (api, schema, sidecar, web),
because "installed and nothing happened" is otherwise a mystery, and it names
what uninstall does NOT do: neither disable nor uninstall deletes anything the
plugin stored, and the screen says so rather than leaving someone to guess
whether a button destroys their data.

a directory whose manifest will not parse is listed with its error rather than
skipped. a malformed plugin that simply does not appear is indistinguishable
from one nobody wrote.

`outdated` is surfaced as an Update button: the version on disk moving after an
install is the normal state on a developer's machine, and it should be visible
rather than inferred.

the four mutations are written out rather than generated in a loop — useMutation
is a hook, and a hook called from inside a helper is a rules-of-hooks violation
even when the call order happens to be stable. caught before it shipped.

verified against a running server: the spa builds (19.8 MB bundle containing the
new screen), / serves 200, /api/plugins answers authenticated and 401s without a
token. full suite 719 pass, same 10 pre-existing failures. live server and
plugin_installs left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:27:58 +00:00
co-authored by Claude Opus 5
parent 282a64a637
commit 98c400bf33
12 changed files with 340 additions and 0 deletions
+1
View File
@@ -64,6 +64,7 @@ export function App() {
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
@@ -149,6 +149,7 @@ import {
Clapperboard,
GitBranch,
Store,
Puzzle,
} from 'lucide-react';
/**
@@ -181,6 +182,8 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// 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' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
];
/**
@@ -0,0 +1,25 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
//
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
// handler. This screen is the courtesy half of that.
//
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
export const PluginsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
/>
</div>
);
};
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
// and destroy the side-by-side. See docs/navigation-audit.md.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'plugins-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
],
};
@@ -0,0 +1 @@
export * from './PluginsScreen';
@@ -1,4 +1,5 @@
export * from './AppStore';
export * from './Plugins';
export * from './Layout';
export * from './Home';
export * from './PasskeyGate';
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
{ 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('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
@@ -1,4 +1,5 @@
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
import { appRegistryMetas as pluginsMetas } from '../apps/Plugins';
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
@@ -45,6 +46,7 @@ export const apps = [
...qrTransferMetas,
...davMetas,
...appStoreMetas,
...pluginsMetas,
];
/**
@@ -0,0 +1,131 @@
import { useSearchParams } from 'react-router';
import { usePlugins, type PluginItem } from './usePlugins';
// The right panel: one plugin, and the four verbs.
//
// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
// anything, so they cannot disagree.
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="flex gap-3 py-1.5 text-sm">
<span className="w-28 shrink-0 text-duck-dark/50">{label}</span>
<span className="min-w-0 text-duck-dark">{children}</span>
</div>
);
const Button = ({
onClick,
disabled,
tone = 'ghost',
children,
}: {
onClick: () => void;
disabled?: boolean;
tone?: 'primary' | 'ghost' | 'danger';
children: React.ReactNode;
}) => {
const tones = {
primary: 'bg-duck-teal text-duck-yellow hover:opacity-90',
ghost: 'border border-duck-dark/20 text-duck-dark hover:bg-duck-dark/5',
danger: 'border border-red-300 text-red-600 hover:bg-red-50',
};
return (
<button
onClick={onClick}
disabled={disabled}
className={`rounded-md px-3 py-1.5 text-sm transition-colors disabled:opacity-40 ${tones[tone]}`}
>
{children}
</button>
);
};
/** What the tree declared. Shown because "installed but nothing happened" is otherwise a mystery. */
const Parts = ({ has }: { has: PluginItem['has'] }) => {
const parts = [
['api', has.api],
['schema', has.schema],
['sidecar', has.sidecar],
['web', has.web],
] as const;
const present = parts.filter(([, yes]) => yes).map(([name]) => name);
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
};
export const PluginDetail = () => {
const { plugins, install, uninstall, enable, disable } = usePlugins();
const [params] = useSearchParams();
const plugin = plugins.find((p) => p.appName === params.get('selected'));
if (!plugin) {
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
}
const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending;
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-1 text-sm text-duck-dark/60">{plugin.summary}</p>
<div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label="Mounts at">
<code>/api{plugin.prefix}</code>
</Row>
<Row label="Publisher">{plugin.publisher}</Row>
<Row label="Version">
{plugin.version}
{plugin.outdated ? (
<span className="ml-2 text-amber-600">on disk installed {plugin.installedVersion}</span>
) : null}
</Row>
<Row label="Needs platform">{plugin.platform}</Row>
<Row label="Ships">
<Parts has={plugin.has} />
</Row>
<Row label="Permissions">
{plugin.permissions.length
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
: 'none — reachable by anyone who can reach the platform'}
</Row>
</div>
<div className="mt-6 flex flex-wrap gap-2">
{!plugin.installed ? (
<Button tone="primary" disabled={busy} onClick={() => install.mutate(plugin.appName)}>
Install
</Button>
) : (
<>
{plugin.enabled ? (
<Button disabled={busy} onClick={() => disable.mutate(plugin.appName)}>
Disable
</Button>
) : (
<Button tone="primary" disabled={busy} onClick={() => enable.mutate(plugin.appName)}>
Enable
</Button>
)}
{plugin.outdated ? (
<Button disabled={busy} onClick={() => install.mutate(plugin.appName)}>
Update to {plugin.version}
</Button>
) : null}
<Button tone="danger" disabled={busy} onClick={() => uninstall.mutate(plugin.appName)}>
Uninstall
</Button>
</>
)}
</div>
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
leaving someone to guess whether the button destroys their data. */}
{plugin.installed ? (
<p className="mt-4 text-xs text-duck-dark/40">
Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install neither deletes
anything the plugin stored.
</p>
) : null}
</div>
);
};
@@ -0,0 +1,63 @@
import { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle } from 'lucide-react';
import { usePlugins, type PluginItem } from './usePlugins';
// The left panel: every plugin in the tree, installed or not.
//
// Rows are real `<Link>`s carrying `?selected=`, not buttons with the name in a closure — so cmd-click,
// middle-click and "copy link" all work, and the detail panel reads the URL rather than being told.
// See docs/navigation-audit.md on the opaque-click anti-pattern.
const Status = ({ plugin }: { plugin: PluginItem }) => {
if (!plugin.installed) return <span className="text-xs text-duck-dark/40">not installed</span>;
if (!plugin.enabled) return <span className="text-xs text-amber-600">disabled</span>;
if (plugin.outdated) return <span className="text-xs text-amber-600">update available</span>;
return <span className="text-xs text-emerald-600">enabled</span>;
};
export const PluginsList = () => {
const { plugins, broken, isLoading } = usePlugins();
const [params] = useSearchParams();
const selected = params.get('selected');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading</div>;
return (
<div className="h-full overflow-auto">
{plugins.length === 0 && broken.length === 0 ? (
<div className="p-4 text-sm text-duck-dark/50">
No plugins in <code>plugins/</code> yet.
</div>
) : null}
{plugins.map((plugin) => (
<Link
key={plugin.appName}
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
className={`flex items-center gap-3 px-3 py-2.5 border-b border-duck-dark/5 transition-colors ${
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
<Puzzle className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
<div className="truncate text-xs text-duck-dark/50">{plugin.prefix}</div>
</div>
<Status plugin={plugin} />
</Link>
))}
{/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
manifest looks exactly like a plugin nobody wrote. */}
{broken.map((b) => (
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
<div className="text-xs text-red-600">{b.error}</div>
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,17 @@
import { Puzzle } from 'lucide-react';
import type { AppRegistryMeta } from '../../AppRegistry';
import { PluginsList } from './PluginsList';
import { PluginDetail } from './PluginDetail';
export { PluginsList } from './PluginsList';
export { PluginDetail } from './PluginDetail';
export { usePlugins } from './usePlugins';
export type { PluginItem, PluginPermission } from './usePlugins';
// Two panels, read side by side, neither telling the other anything — the selection is `?selected=` and
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
// /plugins, together.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
];
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
// Reading and driving the plugin system. One query, four verbs.
//
// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
// asking questions; this installs plugins from the tree and asks nothing.
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
export type PluginItem = {
appName: string;
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
prefix: string;
label: string;
summary: string;
icon: string;
color: string;
publisher: string;
version: string;
platform: string;
permissions: PluginPermission[];
/** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
installed: boolean;
enabled: boolean;
installedVersion: string | null;
/** The code on disk moved after it was installed — normal while developing, and worth seeing. */
outdated: boolean;
};
export type BrokenPlugin = { appName: string; error: string };
const PLUGINS_KEY = ['plugins'];
export function usePlugins() {
const client = useClient();
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: PLUGINS_KEY,
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
});
// Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
// and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
};
// Written out rather than generated in a loop: `useMutation` is a hook, and a hook called from inside a
// helper is a rules-of-hooks violation even when the call order happens to be stable.
const install = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}),
onSuccess: invalidate,
});
const uninstall = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}),
onSuccess: invalidate,
});
const enable = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}),
onSuccess: invalidate,
});
const disable = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}),
onSuccess: invalidate,
});
return {
plugins: data?.plugins ?? [],
broken: data?.broken ?? [],
isLoading,
error,
install,
uninstall,
enable,
disable,
};
}