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:
@@ -91,13 +91,15 @@ export function App() {
|
|||||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||||
|
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||||
|
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
|
||||||
{installedPlugins.flatMap((plugin) => [
|
{installedPlugins.flatMap((plugin) => [
|
||||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||||
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
||||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||||
// passed between them.
|
// passed between them.
|
||||||
<Route
|
<Route
|
||||||
key={`${plugin.appName}-section`}
|
key={`${plugin.screenId}-section`}
|
||||||
path={`${plugin.route}/:section`}
|
path={`${plugin.route}/:section`}
|
||||||
element={<Dashboard.PluginScreen {...plugin} />}
|
element={<Dashboard.PluginScreen {...plugin} />}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -18,11 +18,18 @@ import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
|||||||
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
|
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
|
||||||
// something else falls back rather than rendering another plugin's panel inside this one.
|
// something else falls back rather than rendering another plugin's panel inside this one.
|
||||||
export function PluginScreen({
|
export function PluginScreen({
|
||||||
appName,
|
screenId,
|
||||||
panels,
|
panels,
|
||||||
layout,
|
layout,
|
||||||
}: {
|
}: {
|
||||||
appName: string;
|
/**
|
||||||
|
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
|
||||||
|
*
|
||||||
|
* A plugin with two screens has two of these, and they must differ: the id is both the layout
|
||||||
|
* persistence key and the owner of the panel allow-list below, so sharing one would have Calendar and
|
||||||
|
* Contacts persisting over each other's sizes and each accepting the other's panels.
|
||||||
|
*/
|
||||||
|
screenId: string;
|
||||||
panels: AppRegistryMeta[];
|
panels: AppRegistryMeta[];
|
||||||
layout: LayoutNode;
|
layout: LayoutNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -32,7 +39,7 @@ export function PluginScreen({
|
|||||||
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
||||||
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
||||||
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
||||||
const workspace = useAuthoredLayout(`screens/plugin/${appName}`, layout);
|
const workspace = useAuthoredLayout(`screens/plugin/${screenId}`, layout);
|
||||||
const allowed = panels.map((panel) => panel.key);
|
const allowed = panels.map((panel) => panel.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -41,6 +41,50 @@ plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
|
|||||||
// panel, enforced here rather than by review — a `web/` directory without both files is refused.
|
// panel, enforced here rather than by review — a `web/` directory without both files is refused.
|
||||||
plant('halfweb', { 'manifest.ts': MANIFEST(), 'web/panels.ts': 'export const appRegistryMetas = [];' });
|
plant('halfweb', { 'manifest.ts': MANIFEST(), 'web/panels.ts': 'export const appRegistryMetas = [];' });
|
||||||
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
|
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
|
||||||
|
|
||||||
|
// ── Multi-screen plugins ──
|
||||||
|
//
|
||||||
|
// One plugin, several frontend routes: caldav is one sidecar and one permission behind Calendar and
|
||||||
|
// Contacts. The directory declares that a screen EXISTS; the manifest says what to call it and which
|
||||||
|
// glyph to draw, because a directory listing cannot say either.
|
||||||
|
const SCREEN_FILES = (segment: string) => ({
|
||||||
|
[`web/${segment}/panels.ts`]: 'export const appRegistryMetas = [];',
|
||||||
|
[`web/${segment}/layout.ts`]: 'export const defaultLayout = {};',
|
||||||
|
});
|
||||||
|
plant('twoscreens', {
|
||||||
|
'manifest.ts': MANIFEST(
|
||||||
|
`screens: [{ segment: 'calendar', label: 'Calendar', icon: 'Calendar' }, { segment: 'contacts', label: 'Contacts' }],`,
|
||||||
|
),
|
||||||
|
...SCREEN_FILES('calendar'),
|
||||||
|
...SCREEN_FILES('contacts'),
|
||||||
|
});
|
||||||
|
// Declared with nothing behind it: a route that would render nothing.
|
||||||
|
plant('ghostscreen', {
|
||||||
|
'manifest.ts': MANIFEST(`screens: [{ segment: 'calendar', label: 'Calendar' }],`),
|
||||||
|
});
|
||||||
|
// The reverse: panels nobody declared, so a screen with no tile and no label.
|
||||||
|
plant('orphanscreen', {
|
||||||
|
'manifest.ts': MANIFEST(`screens: [{ segment: 'calendar', label: 'Calendar' }],`),
|
||||||
|
...SCREEN_FILES('calendar'),
|
||||||
|
...SCREEN_FILES('contacts'),
|
||||||
|
});
|
||||||
|
// A chosen segment is a chosen URL, and only a first-party plugin may have one — otherwise any installed
|
||||||
|
// plugin could shadow a core route, which `mountPrefix` exists to make impossible.
|
||||||
|
plant('thirdparty', {
|
||||||
|
'manifest.ts': `export const manifest = {
|
||||||
|
publisher: 'someone-else', version: '1.0.0', platform: '>=1.0.0',
|
||||||
|
label: 'X', summary: 'x', icon: 'Network', color: '#fff',
|
||||||
|
permissions: [], screens: [{ segment: 'calendar', label: 'Calendar' }],
|
||||||
|
};`,
|
||||||
|
...SCREEN_FILES('calendar'),
|
||||||
|
});
|
||||||
|
// A segment is a URL segment. `../escape` would put the route somewhere other than where it reads, so it
|
||||||
|
// is refused rather than escaped. `web/` exists here on purpose, or the no-web/ check fires first and the
|
||||||
|
// segment rule is never reached.
|
||||||
|
plant('badsegment', {
|
||||||
|
'manifest.ts': MANIFEST(`screens: [{ segment: '../escape', label: 'X' }],`),
|
||||||
|
'web/.keep': '',
|
||||||
|
});
|
||||||
plant('_scratch', { 'manifest.ts': MANIFEST() });
|
plant('_scratch', { 'manifest.ts': MANIFEST() });
|
||||||
|
|
||||||
describe('discoverPlugins', () => {
|
describe('discoverPlugins', () => {
|
||||||
@@ -50,8 +94,11 @@ describe('discoverPlugins', () => {
|
|||||||
expect(full.api).toContain('api/router.ts');
|
expect(full.api).toContain('api/router.ts');
|
||||||
expect(full.schema).toContain('db/schema.ts');
|
expect(full.schema).toContain('db/schema.ts');
|
||||||
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
|
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
|
||||||
expect(full.web?.panels).toContain('web/panels.ts');
|
// The flat form is one screen with no segment — every plugin but caldav.
|
||||||
expect(full.web?.layout).toContain('web/layout.ts');
|
expect(full.web?.screens).toHaveLength(1);
|
||||||
|
expect(full.web?.screens[0]!.segment).toBeNull();
|
||||||
|
expect(full.web?.screens[0]!.panels).toContain('web/panels.ts');
|
||||||
|
expect(full.web?.screens[0]!.layout).toContain('web/layout.ts');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a manifest alone is a valid plugin — every other part is optional', async () => {
|
it('a manifest alone is a valid plugin — every other part is optional', async () => {
|
||||||
@@ -75,7 +122,15 @@ describe('discoverPlugins', () => {
|
|||||||
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
|
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
|
||||||
it('collects broken plugins instead of throwing', async () => {
|
it('collects broken plugins instead of throwing', async () => {
|
||||||
const { plugins, broken } = await discoverPlugins(root);
|
const { plugins, broken } = await discoverPlugins(root);
|
||||||
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'halfweb', 'nomanifest']);
|
expect(broken.map((b) => b.appName).sort()).toEqual([
|
||||||
|
'badsegment',
|
||||||
|
'broken',
|
||||||
|
'ghostscreen',
|
||||||
|
'halfweb',
|
||||||
|
'nomanifest',
|
||||||
|
'orphanscreen',
|
||||||
|
'thirdparty',
|
||||||
|
]);
|
||||||
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
|
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
|
||||||
expect(plugins.length).toBeGreaterThan(0);
|
expect(plugins.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -98,3 +153,45 @@ describe('discoverPlugins', () => {
|
|||||||
expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] });
|
expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a plugin with more than one screen', () => {
|
||||||
|
it('reads one screen per declared segment, in manifest order', async () => {
|
||||||
|
const { plugins } = await discoverPlugins(root);
|
||||||
|
const two = plugins.find((p) => p.appName === 'twoscreens')!;
|
||||||
|
expect(two.web?.screens.map((s) => s.segment)).toEqual(['calendar', 'contacts']);
|
||||||
|
expect(two.web?.screens.map((s) => s.label)).toEqual(['Calendar', 'Contacts']);
|
||||||
|
expect(two.web?.screens[0]!.panels).toContain(join('web', 'calendar', 'panels.ts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a screen with no icon falls back to the plugin's", async () => {
|
||||||
|
const { plugins } = await discoverPlugins(root);
|
||||||
|
const two = plugins.find((p) => p.appName === 'twoscreens')!;
|
||||||
|
expect(two.web?.screens[0]!.icon).toBe('Calendar');
|
||||||
|
expect(two.web?.screens[1]!.icon).toBe('Network');
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorFor = async (appName: string) => {
|
||||||
|
const { broken } = await discoverPlugins(root);
|
||||||
|
return broken.find((b) => b.appName === appName)?.error ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
it('refuses a manifest that declares screens with no web/ at all', async () => {
|
||||||
|
expect(await errorFor('ghostscreen')).toContain('no web/ directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses panels nobody declared — it would have no dock tile', async () => {
|
||||||
|
const orphan = await errorFor('orphanscreen');
|
||||||
|
expect(orphan).toContain('web/contacts/');
|
||||||
|
expect(orphan).toContain('no dock tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a THIRD-PARTY plugin choosing its own URLs', async () => {
|
||||||
|
// The whole reason a chosen segment is safe: `mountPrefix` confines third parties to /p/<publisher>/,
|
||||||
|
// and a segment steps out of it. Without this check, any installed plugin could shadow a core route.
|
||||||
|
expect(await errorFor('thirdparty')).toContain('first-party');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a segment that is not a URL segment', async () => {
|
||||||
|
expect(await errorFor('badsegment')).toContain('lowercase letters');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { PLATFORM_DIR } from '../data-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';
|
import { importFresh } from './import-fresh';
|
||||||
|
|
||||||
// Finding plugins on disk.
|
// Finding plugins on disk.
|
||||||
@@ -54,10 +60,28 @@ 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 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.
|
* 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');
|
const webDir = join(dir, 'web');
|
||||||
if (!existsSync(webDir)) return { web: null };
|
const declared = manifest?.screens ?? [];
|
||||||
|
|
||||||
|
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 panels = join(webDir, 'panels.ts');
|
||||||
const layout = join(webDir, 'layout.ts');
|
const layout = join(webDir, 'layout.ts');
|
||||||
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
|
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
|
||||||
@@ -69,7 +93,59 @@ function findWeb(dir: string, appName: string): { web: DiscoveredPlugin['web'];
|
|||||||
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { web: { panels, layout } };
|
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}: 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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
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);
|
const problems = manifestProblems(appName, manifest);
|
||||||
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
|
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);
|
if (web.error) throw new Error(web.error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -62,23 +62,43 @@ export function generatePluginsModule(states: PluginState[]): string {
|
|||||||
const imports: string[] = [];
|
const imports: string[] = [];
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
|
|
||||||
withWeb.forEach((state, i) => {
|
// One entry per SCREEN, not per plugin. Almost every plugin has exactly one and this reads the same as
|
||||||
const { plugin } = state;
|
// it always did; caldav has two, so it emits two routes, two layout keys and two dock tiles from one
|
||||||
|
// install. The alias counter runs over screens for that reason — two screens of one plugin must not
|
||||||
|
// share an import name.
|
||||||
|
let i = 0;
|
||||||
|
for (const { plugin } of withWeb) {
|
||||||
|
for (const screen of plugin.web!.screens) {
|
||||||
const alias = `Plugin${i}`;
|
const alias = `Plugin${i}`;
|
||||||
imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`);
|
i += 1;
|
||||||
imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(plugin.web!.layout)}';`);
|
imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(screen.panels)}';`);
|
||||||
|
imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(screen.layout)}';`);
|
||||||
|
// The screen id is the LAYOUT KEY (`screens/plugin/<id>`) and the panel allow-list's owner, so two
|
||||||
|
// screens of one plugin must not share it — otherwise they persist over each other's sizes and each
|
||||||
|
// would accept the other's panels.
|
||||||
|
const screenId = screen.segment ? `${plugin.appName}/${screen.segment}` : plugin.appName;
|
||||||
|
const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin);
|
||||||
entries.push(
|
entries.push(
|
||||||
` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', panels: ${alias}Panels, layout: ${alias}Layout },`,
|
` { appName: '${plugin.appName}', screenId: '${screenId}', route: '${route}', panels: ${alias}Panels, layout: ${alias}Layout },`,
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = `${HEADER}
|
const body = `${HEADER}
|
||||||
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
|
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
|
||||||
${imports.join('\n')}
|
${imports.join('\n')}
|
||||||
|
|
||||||
export type GeneratedPlugin = {
|
export type GeneratedPlugin = {
|
||||||
|
/** The plugin this screen belongs to. Two screens of one plugin share it. */
|
||||||
appName: string;
|
appName: string;
|
||||||
/** Where it mounts, from \`mountPrefix\` — \`/offscale\` for ours, \`/p/<publisher>/<name>\` for others. */
|
/**
|
||||||
|
* This SCREEN's id — \`<appName>\` for a single-screen plugin, \`<appName>/<segment>\` for one of several.
|
||||||
|
*
|
||||||
|
* It is the layout persistence key and the panel allow-list's owner, so it has to be unique per screen
|
||||||
|
* rather than per plugin: sharing it would have two screens persisting over each other's sizes.
|
||||||
|
*/
|
||||||
|
screenId: string;
|
||||||
|
/** Where it mounts — \`mountPrefix\` normally, or a first-party plugin's chosen segment. */
|
||||||
route: string;
|
route: string;
|
||||||
/** The panels it contributes. At least one, or discovery refuses the plugin. */
|
/** The panels it contributes. At least one, or discovery refuses the plugin. */
|
||||||
panels: AppRegistryMeta[];
|
panels: AppRegistryMeta[];
|
||||||
|
|||||||
@@ -238,6 +238,30 @@ export type PluginManifest = {
|
|||||||
|
|
||||||
permissions: PluginPermission[];
|
permissions: PluginPermission[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frontend screens this plugin contributes, when it contributes more than one.
|
||||||
|
*
|
||||||
|
* Omit it and the plugin has exactly one screen at its mount prefix, labelled `label` — which is every
|
||||||
|
* plugin but caldav, and the reason nothing else had to change when this arrived.
|
||||||
|
*
|
||||||
|
* ── Why the label is HERE and the existence is on disk ──
|
||||||
|
*
|
||||||
|
* Presence is the declaration everywhere else in this file, and it still is: `web/<segment>/panels.ts`
|
||||||
|
* is what makes a screen exist. What a directory listing cannot say is what to CALL it and which glyph
|
||||||
|
* to draw, and those are exactly what this file is for — a human choice, not a structural fact.
|
||||||
|
*
|
||||||
|
* The two are cross-checked at discovery and a mismatch is REFUSED, not reconciled: a segment here with
|
||||||
|
* no directory, or a directory with no entry here, names a screen that would render nothing or a screen
|
||||||
|
* with no tile. Both are the quiet kind of broken.
|
||||||
|
*
|
||||||
|
* ── The segment is a URL a first-party plugin chooses ──
|
||||||
|
*
|
||||||
|
* `/calendar` and `/contacts`, not `/caldav/calendar`. That is only sound because `mountPrefix` confines
|
||||||
|
* third parties to `/p/<publisher>/` — so a third-party plugin declaring `screens` is refused. Without
|
||||||
|
* that check this field would be a way for any installed plugin to shadow a core route.
|
||||||
|
*/
|
||||||
|
screens?: { segment: string; label: string; icon?: string }[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paths under this plugin's own mount that are served ABOVE the account gate.
|
* Paths under this plugin's own mount that are served ABOVE the account gate.
|
||||||
*
|
*
|
||||||
@@ -478,7 +502,36 @@ export type DiscoveredPlugin = {
|
|||||||
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
|
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
|
||||||
* with a partial frontend.
|
* with a partial frontend.
|
||||||
*/
|
*/
|
||||||
web: { panels: string; layout: string } | null;
|
web: { screens: DiscoveredScreen[] } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One frontend screen: a route, the panels on it, and how they are arranged.
|
||||||
|
*
|
||||||
|
* ── Why a plugin can have more than one ──
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* ── segment ──
|
||||||
|
*
|
||||||
|
* `null` for the ordinary plugin, which means "mount at the prefix" and is every plugin but this one. A
|
||||||
|
* named segment is a first-party plugin choosing its own URL — `/calendar`, not `/caldav/calendar` — which
|
||||||
|
* is only safe because `mountPrefix` still confines third parties to `/p/<publisher>/`. A third-party
|
||||||
|
* plugin naming a segment is refused at discovery, and that check is what keeps this from becoming a way
|
||||||
|
* to shadow a core route.
|
||||||
|
*/
|
||||||
|
export type DiscoveredScreen = {
|
||||||
|
/** Directory name under `web/`, or null for the flat single-screen form. */
|
||||||
|
segment: string | null;
|
||||||
|
/** Dock tile label. The manifest's own `label` for the flat form. */
|
||||||
|
label: string;
|
||||||
|
/** Lucide icon NAME for this screen's tile, falling back to the plugin's. */
|
||||||
|
icon?: string;
|
||||||
|
panels: string;
|
||||||
|
layout: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -289,23 +289,37 @@ export async function pluginDockManifests(): Promise<
|
|||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const { states } = await snapshotPlugins();
|
const { states } = await snapshotPlugins();
|
||||||
|
// One tile per SCREEN. A plugin with two screens gets two tiles behind ONE permission — caldav is
|
||||||
|
// Calendar and Contacts, which are two places to go and one thing to be allowed to reach. Filtering
|
||||||
|
// stays per-plugin for that reason: the permission is the plugin's, and a screen is not a smaller
|
||||||
|
// grant, it is another door into the same room.
|
||||||
return states
|
return states
|
||||||
.filter((s) => s.install?.enabled && s.plugin.web)
|
.filter((s) => s.install?.enabled && s.plugin.web)
|
||||||
.map(({ plugin }) => {
|
.flatMap(({ plugin }) =>
|
||||||
const prefix = mountPrefix(plugin);
|
plugin.web!.screens.map((screen) => {
|
||||||
|
const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin);
|
||||||
return {
|
return {
|
||||||
sidecarId: plugin.appName,
|
sidecarId: plugin.appName,
|
||||||
permission: plugin.manifest.permissions[0]?.key ?? null,
|
permission: plugin.manifest.permissions[0]?.key ?? null,
|
||||||
name: plugin.manifest.label,
|
name: screen.label,
|
||||||
// A shipped `assets/icon.png` wins; the lucide NAME is the fallback for a plugin with no
|
// A shipped `assets/icon.png` wins; the lucide NAME is the fallback for a plugin with no
|
||||||
// artwork of its own. The dock renders `image` as an <img> and `icon` through `resolveIcon`,
|
// artwork of its own. The dock renders `image` as an <img> and `icon` through `resolveIcon`,
|
||||||
// and it already preferred the image — this is the first thing to give it one.
|
// 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 }),
|
//
|
||||||
|
// A plugin with several screens is the one case where the shared artwork is WRONG: two
|
||||||
|
// identical tiles side by side say nothing about which is which. So a screen naming its own
|
||||||
|
// glyph overrides the image, and one that does not falls back to the plugin's.
|
||||||
|
...(screen.icon && plugin.web!.screens.length > 1
|
||||||
|
? { icon: screen.icon }
|
||||||
|
: plugin.icon
|
||||||
|
? { image: iconUrl(plugin.appName) }
|
||||||
|
: { icon: screen.icon ?? plugin.manifest.icon }),
|
||||||
// Artwork defaults to `bare`, a glyph to `badge`; the manifest overrides either way.
|
// Artwork defaults to `bare`, a glyph to `badge`; the manifest overrides either way.
|
||||||
tile: plugin.manifest.tile ?? (plugin.icon ? 'bare' : 'badge'),
|
tile: plugin.manifest.tile ?? (plugin.icon && plugin.web!.screens.length === 1 ? 'bare' : 'badge'),
|
||||||
color: plugin.manifest.color,
|
color: plugin.manifest.color,
|
||||||
rootRoute: prefix,
|
rootRoute: route,
|
||||||
routes: [prefix],
|
routes: [route],
|
||||||
};
|
};
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user