dock: the shell keeps its own items, sidecars contribute theirs
ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin, Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar meant editing the shell. Neither survives sidecars shipping from their own repositories. Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the app's own screens, and Gitea, which is in the light profile because it fronts a remote instance. Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities. Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing for something installed. Filtered by capability server-side too: a member is not handed the manifest of a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until someone opens the network tab. Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled` makes its tile leave and return with no process touched. Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default dock came up a tile short on any machine where Music was never installed — a default that references an optional feature is how an app looks subtly wrong on a fresh install for no stated reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<CatalogueEntry['ui']>>;
|
||||
/** Capability keys whose sidecar is not installed, or is installed but disabled. */
|
||||
unavailable: Set<string>;
|
||||
/**
|
||||
@@ -51,7 +59,9 @@ export async function capabilityAvailability(): Promise<Availability> {
|
||||
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<Availability> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user