a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and a frontend — happened to be the same string. caldav is where that stops holding: one sidecar, one permission, one install, and two screens that have nothing to do with each other on screen. calendar and contacts are not sections of one page, and forcing them into one would be a worse product than the core screens they replace. nothing else moved. all eighteen plugins with a frontend use flat web/panels.ts, which is now "one screen, at the mount prefix, labelled by the manifest" — the same file, the same route, the same persisted layout key. the nested form is opt-in and caldav is its only user. the split of who declares what follows the rule this file already had. the DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST says what to call it and which glyph to draw, because a directory listing cannot say either and those are a human's choice. the two are cross-checked and a mismatch is refused rather than reconciled: a segment with no directory renders nothing, a directory with no entry has no tile and no label, and both are the quiet kind of broken. the part that needed the most care is that a segment is a URL the plugin CHOSE — /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a plugin to shadow a core route, and a chosen segment steps straight out of it. so this is first-party only, refused at discovery, and tested: without that check this field is a way for any installed plugin to take over /files. screenId, not appName, is now the layout key and the panel allow-list's owner. two screens sharing one would persist over each other's sizes and each would accept the other's panels. existing plugins get screenId === appName, so no stored layout is orphaned — verified by regenerating.
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './manifest';
|
||||
import {
|
||||
manifestProblems,
|
||||
FIRST_PARTY_PUBLISHER,
|
||||
type DiscoveredPlugin,
|
||||
type DiscoveredScreen,
|
||||
type PluginManifest,
|
||||
} from './manifest';
|
||||
import { importFresh } from './import-fresh';
|
||||
|
||||
// Finding plugins on disk.
|
||||
@@ -54,22 +60,92 @@ function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
|
||||
* workspace with at least one panel" is enforced: there is no way to ship a frontend that is not a
|
||||
* workspace, because the plugin never renders the screen — it hands over panels and an arrangement.
|
||||
*/
|
||||
function findWeb(dir: string, appName: string): { web: DiscoveredPlugin['web']; error?: string } {
|
||||
function findWeb(
|
||||
dir: string,
|
||||
appName: string,
|
||||
manifest: PluginManifest | null,
|
||||
): { web: DiscoveredPlugin['web']; error?: string } {
|
||||
const webDir = join(dir, 'web');
|
||||
if (!existsSync(webDir)) return { web: null };
|
||||
const declared = manifest?.screens ?? [];
|
||||
|
||||
const panels = join(webDir, 'panels.ts');
|
||||
const layout = join(webDir, 'layout.ts');
|
||||
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
|
||||
if (missing.length) {
|
||||
if (!existsSync(webDir)) {
|
||||
// No `web/` and no claim to one is the ordinary backend-only plugin. No `web/` while DECLARING
|
||||
// screens is a manifest describing a frontend that is not there — every route it names would 404,
|
||||
// and the dock would draw tiles pointing at them.
|
||||
if (!declared.length) return { web: null };
|
||||
return {
|
||||
web: null,
|
||||
error: `${appName}: declares ${declared.length} screen(s) but has no web/ directory.`,
|
||||
};
|
||||
}
|
||||
|
||||
// The flat form: one screen at the mount prefix. Every plugin but caldav, and the reason adding
|
||||
// multi-screen support moved none of them.
|
||||
if (!declared.length) {
|
||||
const panels = join(webDir, 'panels.ts');
|
||||
const layout = join(webDir, 'layout.ts');
|
||||
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
|
||||
if (missing.length) {
|
||||
return {
|
||||
web: null,
|
||||
error:
|
||||
`${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
|
||||
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
web: { screens: [{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, panels, layout }] },
|
||||
};
|
||||
}
|
||||
|
||||
// A named segment is a URL the plugin chose rather than one derived from its name, and `mountPrefix`
|
||||
// is what stops that being a way to shadow a core route — it confines third parties to
|
||||
// `/p/<publisher>/`, which a chosen segment would step straight out of. So this is first-party only,
|
||||
// refused here rather than anywhere later: by the time a route exists it is already serving.
|
||||
if (manifest && manifest.publisher !== FIRST_PARTY_PUBLISHER) {
|
||||
return {
|
||||
web: null,
|
||||
error:
|
||||
`${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
|
||||
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
||||
`${appName}: declares screens, which chooses its own URLs, and only a first-party plugin may. ` +
|
||||
`A third-party plugin mounts under /p/${manifest.publisher}/ so it cannot shadow a core route.`,
|
||||
};
|
||||
}
|
||||
return { web: { panels, layout } };
|
||||
|
||||
const screens: DiscoveredScreen[] = [];
|
||||
const problems: string[] = [];
|
||||
|
||||
for (const screen of declared) {
|
||||
// A segment is a URL segment. Anything else — a separator, a traversal, a space — would land the
|
||||
// route somewhere other than where it reads, so it is refused rather than escaped.
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(screen.segment)) {
|
||||
problems.push(`screen segment ${JSON.stringify(screen.segment)} must be lowercase letters, digits and dashes`);
|
||||
continue;
|
||||
}
|
||||
const panels = join(webDir, screen.segment, 'panels.ts');
|
||||
const layout = join(webDir, screen.segment, 'layout.ts');
|
||||
const missing = [
|
||||
!existsSync(panels) && `web/${screen.segment}/panels.ts`,
|
||||
!existsSync(layout) && `web/${screen.segment}/layout.ts`,
|
||||
].filter(Boolean);
|
||||
if (missing.length) {
|
||||
problems.push(`screen "${screen.segment}" is declared but missing ${missing.join(' and ')}`);
|
||||
continue;
|
||||
}
|
||||
screens.push({ segment: screen.segment, label: screen.label, icon: screen.icon ?? manifest?.icon, panels, layout });
|
||||
}
|
||||
|
||||
// A directory nobody declared is a screen with no tile and no label — it would render at a URL with
|
||||
// nothing pointing at it. Reported for the same reason the reverse is: both are the quiet kind of broken.
|
||||
const undeclared = readdirSync(webDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && existsSync(join(webDir, entry.name, 'panels.ts')))
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => !declared.some((screen) => screen.segment === name));
|
||||
for (const name of undeclared) {
|
||||
problems.push(`web/${name}/ has panels but no screens entry — it would have no dock tile and no label`);
|
||||
}
|
||||
|
||||
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
|
||||
return { web: { screens } };
|
||||
}
|
||||
|
||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||
@@ -97,7 +173,7 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
|
||||
const problems = manifestProblems(appName, manifest);
|
||||
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
|
||||
|
||||
const web = findWeb(dir, appName);
|
||||
const web = findWeb(dir, appName, manifest);
|
||||
if (web.error) throw new Error(web.error);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user