app store: publish a sidecar's assets to public/plugins/<id>/
Real PNG icons are coming, so this is the path they arrive by: a sidecar ships its assets beside its own code, and install copies them to public/plugins/<id>/ where one static route serves them. Copied rather than served in place because a sidecar shipping from its own repository has its assets wherever that repository was unpacked, which is not a path the web server can be taught at build time. One predictable destination means the serving rule never has to know how many plugins exist or where any came from. It also makes assets a property of the INSTALL: uninstall removes them, and a plugin nobody installed serves nothing. Needed a new route, and the reason is a trap worth recording. `publicRoutes` in server.tsx is built by globbing ./public at BOOT, so anything copied there afterwards is invisible to it — the first install of a plugin would show a broken image until the server was restarted, and "install it, then restart to see the icon" is not an install. `/plugins/*` resolves per request, like /novnc/* and /vendor/* already do. Unlike those two it answers 404 rather than 500 for a missing file: an unpublished icon is an ordinary state on a fresh machine, and a 500 would put a red line in the log for every dock render. Proven end to end with a real asset: slskd's icon moved from public/slskd.png into the sidecar's own assets/, published against an ALREADY RUNNING server, and fetched at 200 with the right bytes and content-type — 404 before publishing, no restart between. public/plugins/ is gitignored: it holds copies, and the originals live with each sidecar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,3 +48,6 @@ src/apps/officer-web/index.gen.html
|
||||
|
||||
# scratch scripts — never commit these
|
||||
*.tmp.ts
|
||||
|
||||
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
|
||||
public/plugins/
|
||||
|
||||
@@ -221,6 +221,20 @@ const server = serve({
|
||||
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||
return new Response(file);
|
||||
},
|
||||
// Icons and assets belonging to installed sidecars, copied to public/plugins/<id>/ by the installer.
|
||||
//
|
||||
// Served by this dynamic route rather than by `publicRoutes` above, which is a snapshot taken by
|
||||
// globbing ./public at BOOT. A plugin installed while the server is running would not be in that map,
|
||||
// so its icon would 404 until the next restart — and "install it, then restart the server to see the
|
||||
// icon" is not an install.
|
||||
'/plugins/*': async (req) => {
|
||||
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||
// 404 rather than letting a missing file surface as a 500. An icon that has not been published
|
||||
// yet is an ordinary state — the plugin is not installed — and a 500 would put a red line in the
|
||||
// log for every dock render on a fresh machine.
|
||||
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
||||
return new Response(file);
|
||||
},
|
||||
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
|
||||
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
|
||||
'/api/vault/notifications/*': (req, server) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cp, rm, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Copying a sidecar's own assets — its icon, and whatever else it ships — to where the browser can
|
||||
// fetch them.
|
||||
//
|
||||
// src/servers/app-store/templates/<template>/assets/ what the sidecar ships
|
||||
// public/plugins/<sidecar-id>/ where it is served from
|
||||
//
|
||||
// ── Why copied rather than served from where they live ──
|
||||
//
|
||||
// A sidecar that ships from its own repository has its assets wherever that repository was unpacked,
|
||||
// which is not a path the web server can be taught at build time. Copying into one predictable place
|
||||
// under `public/` means the serving rule is a single route (`/plugins/*` in server.tsx) that never has
|
||||
// to know how many plugins exist or where any of them came from.
|
||||
//
|
||||
// It also means the assets are a property of the INSTALL rather than of the source tree: uninstall
|
||||
// removes them, and a plugin that was never installed serves nothing.
|
||||
//
|
||||
// ── The boot-snapshot trap ──
|
||||
//
|
||||
// `publicRoutes` in server.tsx is built by globbing ./public at startup, so anything copied here after
|
||||
// boot is invisible to it. That is why `/plugins/*` exists as a dynamic route — without it the first
|
||||
// install of a plugin would show a broken image until the server was restarted.
|
||||
|
||||
/** Where a sidecar's shipped assets live in the source tree. */
|
||||
export const assetSourceDir = (templateDir: string): string => join(templateDir, 'assets');
|
||||
|
||||
/** Where they are served from. Matches the `/plugins/*` route and the `image` paths in a UI manifest. */
|
||||
export const assetPublicDir = (sidecarId: string): string => join(process.cwd(), 'public', 'plugins', sidecarId);
|
||||
|
||||
/** The URL a manifest should use for a shipped icon. */
|
||||
export const iconUrl = (sidecarId: string): string => `/plugins/${sidecarId}/icon.png`;
|
||||
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish a sidecar's assets. Returns false when it ships none, which is not a failure — most sidecars
|
||||
* use a lucide glyph and have nothing to copy.
|
||||
*
|
||||
* Idempotent by overwriting: re-running an install republishes rather than erroring on a directory that
|
||||
* already exists, which is what makes this safe inside a resumable step.
|
||||
*/
|
||||
export async function publishAssets(sidecarId: string, templateDir: string): Promise<boolean> {
|
||||
const source = assetSourceDir(templateDir);
|
||||
if (!(await exists(source))) return false;
|
||||
|
||||
await cp(source, assetPublicDir(sidecarId), { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove them on uninstall.
|
||||
*
|
||||
* Safe to delete, unlike everything else uninstall touches: these are copies of files that still exist
|
||||
* in the sidecar's own source. Nothing a user made is in here, which is exactly why this is the one
|
||||
* thing uninstall is allowed to remove.
|
||||
*/
|
||||
export async function unpublishAssets(sidecarId: string): Promise<void> {
|
||||
await rm(assetPublicDir(sidecarId), { recursive: true, force: true });
|
||||
}
|
||||
@@ -262,7 +262,13 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
},
|
||||
{
|
||||
id: 'slskd',
|
||||
ui: { name: 'Soulseek', image: '/slskd.png', color: '#ffffff', rootRoute: '/soulseek', routes: ['/soulseek'] },
|
||||
ui: {
|
||||
name: 'Soulseek',
|
||||
image: '/plugins/slskd/icon.png',
|
||||
color: '#ffffff',
|
||||
rootRoute: '/soulseek',
|
||||
routes: ['/soulseek'],
|
||||
},
|
||||
process: 'officer-slskd',
|
||||
label: 'Soulseek',
|
||||
summary: 'slskd — search and download from the Soulseek network',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Reference in New Issue
Block a user