feat: shell controller, TabStage, layout and theme plugins with repair view

This commit is contained in:
LukeGus committed 2026-10-18 16:37:36 -05:00
1 parent aff29a9581
commit 86161d66e2
120 files changed
+23693 -3381

No files matched your search

+164 -1
View File
@@ -19,6 +19,7 @@ import {
type PluginEnvVar,
} from "./docs.js";
export { validateThemeCss } from "./theme-css.js";
export {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
@@ -36,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.3.0";
export const PLUGIN_API_VERSION = "1.4.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
keyboardInteractive?: string[];
}
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
/**
* A whole-app layout the frontend registers with app.registerLayout. Needs
* the ui:layout capability. Declared here so core can list and choose it
* before any plugin code runs.
*/
export interface PluginLayoutContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
description?: string;
/** Theme picked with this layout when nobody chose one. */
defaultTheme?: string;
/** The layout a default-look plugin starts with. */
default?: boolean;
}
/**
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
*/
export interface PluginThemeContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
mode: "light" | "dark";
/** Path of the CSS file inside the plugin. */
css: string;
/** Colors the theme picker previews it with. */
swatch?: { background: string; panel: string; text: string };
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
@@ -487,6 +522,10 @@ export interface PluginContributions {
* ctx.credentials.resolveHostProtocol.
*/
protocols?: PluginProtocolContribution[];
/** Whole-app layouts. Needs the ui:layout capability. */
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
}
/** A field a protocol login carries besides username and password. */
@@ -708,6 +747,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"uses",
"keybindingActions",
"protocols",
"layouts",
"themes",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -1300,6 +1341,111 @@ function validateContributes(
validateUiPresets(contributes.uiPresets, errors);
validateKeybindingActions(contributes.keybindingActions, errors);
validateProtocols(contributes.protocols, errors);
validateLayouts(contributes.layouts, errors);
validateThemes(contributes.themes, errors);
}
/** An array of objects with unique look ids; calls `each` for every valid entry. */
function validateLookList(
value: unknown,
field: string,
allowed: string[],
errors: string[],
each: (raw: Record<string, unknown>, where: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`Field "contributes.${field}" must be an array`);
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const where = `contributes.${field}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, allowed, where, errors);
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
errors.push(
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.label, `${where}.label`, errors);
each(raw, where);
});
}
function validateLayouts(value: unknown, errors: string[]): void {
validateLookList(
value,
"layouts",
["id", "label", "description", "defaultTheme", "default"],
errors,
(raw, where) => {
if (raw.description !== undefined)
requireString(raw.description, `${where}.description`, errors);
if (
raw.defaultTheme !== undefined &&
(typeof raw.defaultTheme !== "string" ||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
) {
errors.push(`${where}.defaultTheme must be a theme id`);
}
if (raw.default !== undefined && typeof raw.default !== "boolean") {
errors.push(`${where}.default must be a boolean`);
}
},
);
if (
Array.isArray(value) &&
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
) {
errors.push('Only one entry in "contributes.layouts" may set default');
}
}
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
function validateThemes(value: unknown, errors: string[]): void {
validateLookList(
value,
"themes",
["id", "label", "mode", "css", "swatch"],
errors,
(raw, where) => {
if (raw.mode !== "light" && raw.mode !== "dark") {
errors.push(`${where}.mode must be "light" or "dark"`);
}
if (requireString(raw.css, `${where}.css`, errors)) {
requireRelativePath(raw.css, `${where}.css`, errors);
if (!raw.css.endsWith(".css")) {
errors.push(`${where}.css must be a .css file`);
}
}
if (raw.swatch !== undefined) {
if (!isPlainObject(raw.swatch)) {
errors.push(`${where}.swatch must be an object`);
return;
}
rejectUnknown(
raw.swatch,
["background", "panel", "text"],
`${where}.swatch`,
errors,
);
for (const key of ["background", "panel", "text"]) {
const color = raw.swatch[key];
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
errors.push(`${where}.swatch.${key} must be a hex color`);
}
}
}
},
);
}
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
@@ -2324,6 +2470,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
if (
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:layout")
) {
errors.push(
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
);
}
if (
(manifest.contributes?.themes?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:theme")
) {
errors.push(
'contributes.themes needs the "ui:theme" capability in the capabilities array',
);
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(