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:
2026-08-10 18:32:16 +00:00
co-authored by Claude Opus 5
parent 209e916343
commit 82e9fdacc2
7 changed files with 160 additions and 36 deletions
+9 -1
View File
@@ -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