diff --git a/plugins/music/assets/icon.png b/plugins/music/assets/icon.png new file mode 100644 index 00000000..65981284 Binary files /dev/null and b/plugins/music/assets/icon.png differ diff --git a/plugins/music/manifest.ts b/plugins/music/manifest.ts index 5f2a41de..4fac7be4 100644 --- a/plugins/music/manifest.ts +++ b/plugins/music/manifest.ts @@ -10,6 +10,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; // api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge // cliamp/ the second playback path, parked // widgets/ the dashboard widget, parked +// assets/icon.png the dock tile, published to /plugins/music/ on install // sidecar/ the whole /api/music contract: indexing, streaming, per-user state // db/ music_favorites, _playlists, _playlist_items, _now_playing // web/ the library panels; the shell renders the Workspace @@ -44,7 +45,9 @@ export const manifest: PluginManifest = { label: 'Music', summary: 'The music library — browse, play, favourites and playlists', - icon: 'Music', + // No `icon` field: this plugin ships `assets/icon.png` and the file wins. A lucide name could only + // ever pick from the 106 glyphs the platform happens to bundle, which is a ceiling a plugin from a + // marketplace cannot see coming — and this one's artwork is a voxel duck in headphones, not a glyph. color: '#22c55e', // One permission gating the whole surface, grantable per role at read or write like every other. diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 71a7d899..d9ee1cf7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -126,27 +126,15 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => { import { Home, MessageCircle, - FileText, FolderOpen, Code, LayoutGrid, - FolderKanban, Monitor, - Mail, Globe, - MonitorSmartphone, Workflow, Music, Activity, Radio, - Network, - ArrowDownUp, - Bitcoin, - Receipt, - Images, - CalendarDays, - Contact, - Clapperboard, GitBranch, Store, Puzzle, diff --git a/src/server.tsx b/src/server.tsx index ddffddaf..e15c2aa3 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -27,8 +27,22 @@ import type { SidecarRegistration } from './servers/sidecar/registration-protoco import { toShellUsername } from './servers/data-path'; // Build static file routes from public/ +// A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file` +// handle opened now. +// +// `plugins/` is EXCLUDED, and that is not an optimisation. Everything under it is published and removed +// at runtime by the plugin installer, and this map is spread into the route table ahead of the +// `/plugins/*` wildcard — so an exact entry here WINS over the dynamic handler that knows how to say +// 404. A plugin whose icon existed at boot and was then uninstalled left a route pointing at a deleted +// file, which answered `500 ENOENT` on every dock render until the next restart, with the error in the +// log each time. Precisely what the wildcard's own comment says it exists to avoid. +// +// The add case was already known and is why the wildcard exists at all. This is its mirror: a snapshot +// cannot describe a directory that changes while the server runs, in either direction. So it does not +// try — `/plugins/*` owns that prefix alone. const publicRoutes: Record Response> = {}; for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) { + if (file.startsWith('plugins/')) continue; const bunFile = Bun.file(`./public/${file}`); publicRoutes[`/${file}`] = () => new Response(bunFile); } diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 9106153a..9473d29a 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -4,6 +4,7 @@ import { isSuperAdmin } from '../../super-admin'; import { mountPrefix } from '../../plugins/manifest'; import { snapshotPlugins } from '../../plugins/mount'; import { manualInstallHint, reportDependencies } from '../../plugins/os-deps'; +import { iconUrl } from '../../app-store/assets'; import { installPlugin, pluginProcessStatus, @@ -65,6 +66,16 @@ pluginsRouter.get('/', async (ctx) => { label: plugin.manifest.label, summary: plugin.manifest.summary, icon: plugin.manifest.icon, + // The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies + // `assets/` into `public/plugins//` at install and `unpublishAssets` removes it, so a + // plugin that has never been installed has nothing at that URL and an would 404. The list + // falls back to a generic glyph, which is what it drew for everything until now. + // + // `[open]` That means you cannot see a plugin's icon BEFORE installing it, which is the one place + // an app store most wants to. Fixing it means either publishing on discovery rather than install — + // which breaks "assets are a property of the install" — or an authenticated icon route, which an + // cannot use because it sends no Authorization header. + ...(plugin.icon && install ? { image: iconUrl(plugin.appName) } : {}), color: plugin.manifest.color, publisher: plugin.manifest.publisher, version: plugin.manifest.version, diff --git a/src/servers/plugins/discover.ts b/src/servers/plugins/discover.ts index f45ee69d..fbdee19c 100644 --- a/src/servers/plugins/discover.ts +++ b/src/servers/plugins/discover.ts @@ -21,6 +21,7 @@ import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './ // sidecar/index.ts a process (`.mjs` instead means node — see below) // web/panels.ts panel apps — REQUIRED with web/ // web/layout.ts how they are arranged — REQUIRED with web/ +// assets/icon.png the dock tile's image, published to public/plugins// on install // // Nothing here reads the database. This answers "what is on disk", which is a different question from // "what is installed" — the install table answers that, and the two disagreeing is a state the app store @@ -100,6 +101,11 @@ export async function loadPlugin(dir: string, appName: string): Promise/assets/` → `public/plugins//` — because it already solves the parts that are not + // obvious: the `/plugins/*` route is dynamic precisely so a first install does not show a broken + // image until the next restart, and uninstall is allowed to delete these because they are copies of + // files that still exist in the plugin's source. + if (plugin.icon) { + await publishAssets(appName, plugin.dir); + await step(steps, onStep, `assets: published to /plugins/${appName}/`); + } + // MOUNT BEFORE STARTING THE SIDECAR, and the order is not cosmetic. // // `createSidecarProxy` learns its sidecar's port from a one-shot event (`:server`), and it @@ -229,7 +240,10 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise await step(steps, onStep, 'sidecar: stopped, deleted, ecosystem entry removed'); } - await step(steps, onStep, 'tables and data: untouched'); + // The one thing uninstall IS allowed to delete: published assets are copies, and the originals are + // still in the plugin's own directory. Nothing a user made is in there. + await unpublishAssets(appName); + await step(steps, onStep, 'tables and data: untouched (published assets removed)'); return { ok: true, appName, steps }; } diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index aeca7f3a..c57927b0 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -97,8 +97,11 @@ export type PluginManifest = { label: string; summary: string; - /** A lucide icon name, resolved at render. */ - icon: string; + /** + * A lucide icon name, resolved at render — the FALLBACK, used only when the plugin ships no + * `assets/icon.png`. Optional for that reason: a plugin with its own artwork has nothing to say here. + */ + icon?: string; /** Tile colour. */ color: string; @@ -128,6 +131,14 @@ export type DiscoveredPlugin = { dir: string; manifest: PluginManifest; + /** + * `assets/icon.png` — the dock tile's image, published to `public/plugins//` on install. + * + * Null means the plugin ships none and falls back to `manifest.icon`, a lucide NAME. The file wins + * because it has no ceiling: `resolveIcon` knows 106 glyphs out of lucide's ~1,500, and a plugin + * naming one outside that set silently renders a neutral box. + */ + icon: string | null; /** `api/router.ts` — a backend router, mounted at `mountPrefix`. */ api: string | null; /** `db/schema.ts` — tables, pushed on install. Every name must be prefixed `_`. */ @@ -250,7 +261,7 @@ export function manifestProblems(appName: string, manifest: Partial and `icon` through `resolveIcon`, + // and it already preferred the image — this is the first thing to give it one. + ...(plugin.icon ? { image: iconUrl(plugin.appName) } : { icon: plugin.manifest.icon }), color: plugin.manifest.color, rootRoute: prefix, routes: [prefix], diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 0b26b47e..acde9ad9 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -120,8 +120,13 @@ export const PluginDetail = () => { return (
-

{plugin.label}

-

{plugin.summary}

+
+ {plugin.image ? : null} +
+

{plugin.label}

+

{plugin.summary}

+
+
diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index 57d17daf..41d9d4ab 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -38,7 +38,13 @@ export const PluginsList = () => { selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5' }`} > - + {/* The plugin's own artwork when it ships some and is installed; otherwise the generic glyph + this drew for everything before plugins could carry an icon. */} + {plugin.image ? ( + + ) : ( + + )}
{plugin.label}
{plugin.prefix}
diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index be09b677..4ec14d2e 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -46,7 +46,10 @@ export type PluginItem = { prefix: string; label: string; summary: string; - icon: string; + /** A lucide NAME, and only when the plugin ships no artwork of its own. */ + icon?: string; + /** The plugin's own `assets/icon.png`, served from `/plugins//`. Present only while installed. */ + image?: string; color: string; publisher: string; version: string;