diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index e01daa9b..3b14035f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -6,7 +6,7 @@ import { ErrorBoundary } from '@/components/ErrorBoundary'; import { ScreenErrorFallback } from './ScreenErrorFallback'; import { Background } from './Background'; import { Header } from './Header'; -import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock'; +import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock'; import { useIsTouch } from './useIsTouch'; import { usePageTitleSync } from '@/state/usePageTitle'; @@ -14,12 +14,17 @@ type DashboardLayoutProps = { children?: React.ReactNode; }; export function DashboardLayout({ children }: DashboardLayoutProps) { - const { canVisit } = useCapabilities(); + const { canVisit, plugins } = useCapabilities(); // Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer // reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic // either way — every one of these routes is refused server-side too — but an app that offers a door it // will then slam is worse than one that never showed it. - const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]); + // The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes + // anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one. + const permitted = useMemo( + () => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)), + [canVisit, plugins], + ); const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS); const isTouch = useIsTouch(); const { pathname } = useLocation(); diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index a27e01d2..0b9beee0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { NavLink } from 'react-router'; import type { LucideIcon } from 'lucide-react'; +import { resolveIcon } from 'officerdev'; +import type { PluginManifest } from 'hooks/useCapabilities'; export type DockItem = { label: string; @@ -149,21 +151,21 @@ import { GitBranch, } from 'lucide-react'; -export const ALL_DOCK_ITEMS: DockItem[] = [ +/** + * The dock items that belong to the SHELL — present on every install, with no sidecar behind them. + * + * Everything else is contributed by an installed sidecar's UI manifest and arrives from + * `/capabilities` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be + * installed and uninstalled must not be hardcoded here, or the dock would list things this server does + * not have and the shell would need editing every time a sidecar is added. + * + * These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in + * the light profile because it fronts a remote instance and installs nothing locally. + */ +export const CORE_DOCK_ITEMS: DockItem[] = [ { label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, { label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, - { label: 'Email', to: '/email', icon: Mail, color: '#ef4444' }, - { label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' }, - { label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, - { label: 'Music', to: '/music', icon: Music, color: '#22c55e' }, - { label: 'Photos', to: '/photos', icon: Images, color: '#10b981' }, - { label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' }, - { label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' }, - { label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' }, - { label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' }, - { label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' }, - { label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' }, { label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' }, { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' }, @@ -171,10 +173,40 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' }, { label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, - { label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' }, { 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' }, ]; -export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat']; +/** + * Turn the manifests of installed sidecars into dock tiles. + * + * `resolveIcon` maps a NAME to a glyph, which is why manifests carry names rather than imports — they + * have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than + * throwing: a plugin naming an icon this build does not have should look plain, not break the dock. + */ +export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] { + return plugins.flatMap((plugin) => { + const tile = (t: { name: string; icon?: string; image?: string; color: string; route: string }): DockItem => ({ + label: t.name, + to: t.route, + color: t.color, + ...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }), + }); + + return [ + tile({ name: plugin.name, icon: plugin.icon, image: plugin.image, color: plugin.color, route: plugin.rootRoute }), + ...(plugin.extraTiles ?? []).map(tile), + ]; + }); +} + +/** + * What is pinned before anyone has chosen. Deliberately drawn only from CORE_DOCK_ITEMS. + * + * This used to pin `/music`, which is now an installable sidecar. `useDock` drops a path with no item + * behind it, so nothing breaks — the default dock just quietly comes up one tile short on a machine + * where Music was never installed. Defaults that reference optional features are how an app ends up + * looking subtly wrong on a fresh install for no stated reason. + */ +export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat']; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx index a168d7f0..c1604e83 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx @@ -1,8 +1,9 @@ -import { useState, useCallback, type DragEvent } from 'react'; +import { useMemo, useState, useCallback, type DragEvent } from 'react'; import { X, Plus, RotateCcw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useDock } from 'officerdev'; -import { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock'; +import { useCapabilities } from 'hooks/useCapabilities'; +import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock'; type DockPillProps = { label: string; @@ -18,14 +19,32 @@ type DockPillProps = { }; const DockPill = ({ - label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill, + label, + path, + color, + visible, + onAction, + onDragStart, + onDropOnPill, + dropIndicator, + onDragOverPill, + onDragLeavePill, }: DockPillProps) => ( onDragStart(ev, path)} onDragOver={onDragOverPill} onDragLeave={onDragLeavePill} - onDrop={onDropOnPill ? (ev) => { ev.preventDefault(); ev.stopPropagation(); const p = ev.dataTransfer.getData('text/plain'); if (p) onDropOnPill(p); } : undefined} + onDrop={ + onDropOnPill + ? (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const p = ev.dataTransfer.getData('text/plain'); + if (p) onDropOnPill(p); + } + : undefined + } className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`} > {dropIndicator === 'left' && } @@ -69,7 +88,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => { return (
- {label} + + {label} +
{ }; export const DockSettings = () => { - const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS); + // Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone + // pin a tile that cannot appear, which reads as the setting being broken. + const { plugins } = useCapabilities(); + const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]); + const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS); const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null); const visiblePaths = new Set(items.map((i) => i.to)); @@ -135,7 +160,10 @@ export const DockSettings = () => { const handlePillDrop = useCallback( (draggedPath: string, targetPath: string) => { - if (draggedPath === targetPath) { setDropTarget(null); return; } + if (draggedPath === targetPath) { + setDropTarget(null); + return; + } const side = dropTarget?.path === targetPath ? dropTarget.side : 'right'; insertAt(draggedPath, targetPath, side); }, @@ -163,7 +191,11 @@ export const DockSettings = () => { return (
- {items.length === 0 && Drag items here to show in dock} + {items.length === 0 && ( + + Drag items here to show in dock + + )} {items.map((item) => ( { - {hiddenItems.length === 0 && All items visible} + {hiddenItems.length === 0 && ( + All items visible + )} {hiddenItems.map((item) => ( { ))} - diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts index 9d16eaf9..72ba7629 100644 --- a/src/servers/api/users/capabilities-routes.ts +++ b/src/servers/api/users/capabilities-routes.ts @@ -32,7 +32,7 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => { // What EXISTS on this server, which is a different question from what this account may use. A // capability the owner holds unconditionally still means nothing if its sidecar was never installed, // and the owner is as subject to that as a member — see app-store/availability.ts. - const { unavailable } = await capabilityAvailability(); + const { unavailable, manifests } = await capabilityAvailability(); // The owner holds everything, and says so by listing it rather than by a flag the frontend has to // remember to special-case. One shape for both audiences means one code path in the UI. @@ -50,6 +50,14 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => { capabilities: held, /** Capabilities the account holds whose sidecar is not installed or is disabled. */ unavailable: [...unavailable].filter((key) => heldKeys.has(key)), + /** + * Dock tiles and routes belonging to installed sidecars the account may reach. + * + * Filtered by capability here rather than in the client: a member must not be handed the manifest + * of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of + * privacy that lasts until someone opens the network tab. + */ + plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)), // Flattened for the dock and the route guard, which care about paths rather than capability keys. routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []), // The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route diff --git a/src/servers/app-store/availability.ts b/src/servers/app-store/availability.ts index 23ecc176..e9e09c22 100644 --- a/src/servers/app-store/availability.ts +++ b/src/servers/app-store/availability.ts @@ -1,5 +1,5 @@ import { listSidecarInstalls } from 'officerdb'; -import { CATALOGUE } from './catalogue'; +import { CATALOGUE, type CatalogueEntry } from './catalogue'; // Which features actually EXIST on this server right now — as opposed to which the account is permitted // to use. @@ -25,6 +25,14 @@ import { CATALOGUE } from './catalogue'; const CAPABILITY_TO_SIDECAR = new Map(CATALOGUE.filter((e) => e.capability).map((e) => [e.capability as string, e.id])); export type Availability = { + /** + * UI manifests of the sidecars that ARE usable — what the dock should show beyond the baseline. + * + * Sent with the capability answer rather than fetched separately so the dock has one source. Two + * requests would mean two moments, and a dock rendered between them shows either a tile for something + * uninstalled or nothing for something installed. + */ + manifests: Array<{ sidecarId: string; capability: string | null } & NonNullable>; /** Capability keys whose sidecar is not installed, or is installed but disabled. */ unavailable: Set; /** @@ -51,7 +59,9 @@ export async function capabilityAvailability(): Promise { try { installs = await listSidecarInstalls(); } catch { - return { unavailable, degraded: true }; + // Degraded: subtract nothing, and offer no manifests. The dock keeps its baseline rather than + // guessing, which is the same fail-open posture as useCapabilities. + return { unavailable, manifests: [], degraded: true }; } const usable = new Set( @@ -62,5 +72,11 @@ export async function capabilityAvailability(): Promise { if (!usable.has(sidecarId)) unavailable.add(capability); } - return { unavailable, degraded: false }; + const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({ + sidecarId: e.id, + capability: e.capability, + ...e.ui!, + })); + + return { unavailable, manifests, degraded: false }; } diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 838c8b44..09362ac6 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -79,6 +79,14 @@ export type UiManifest = { * exactly one. */ routes: string[]; + /** + * Additional dock tiles, for the rare sidecar that presents as more than one thing. + * + * CalDAV is the only case today: one sidecar, but Calendar and Contacts are separate features to + * anyone using them, and collapsing them into one tile to keep the model tidy would make the app + * worse. Every route here must still appear in `routes`. + */ + extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>; }; export type CatalogueEntry = { diff --git a/src/workspaces/hooks/src/useCapabilities.ts b/src/workspaces/hooks/src/useCapabilities.ts index a17560a9..40c7a755 100644 --- a/src/workspaces/hooks/src/useCapabilities.ts +++ b/src/workspaces/hooks/src/useCapabilities.ts @@ -27,6 +27,29 @@ export type SelfCapabilities = { * guard that cannot tell those apart either blanks the app or guards nothing. */ deniedRoutes: string[]; + /** Capabilities the account holds whose sidecar is not installed, or is installed but disabled. */ + unavailable?: string[]; + /** + * Dock tiles and routes of the sidecars actually installed on this server. + * + * The dock is the baseline shell plus these. A feature appears when it is installed and leaves when it + * is removed, with nothing in the shell to edit — which is what lets a sidecar ship from its own + * repository and still show up. + */ + plugins?: PluginManifest[]; +}; + +/** What an installed sidecar says about how it should appear. Mirrors UiManifest on the server. */ +export type PluginManifest = { + sidecarId: string; + capability: string | null; + name: string; + icon?: string; + image?: string; + color: string; + rootRoute: string; + routes: string[]; + extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>; }; export const CAPABILITIES_QUERY_KEY = ['self-capabilities']; @@ -80,6 +103,9 @@ export function useCapabilities() { isOwner: data?.isOwner ?? false, capabilities: held, routes: data?.routes ?? [], + // Empty rather than undefined when the request has not landed or failed: the dock then renders its + // baseline, which is the honest "we do not know yet" — not an empty app. + plugins: data?.plugins ?? [], isLoading, isError, can,