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

+36 -3
View File
@@ -1,4 +1,5 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
// CSS files its themes declare.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
/** Changes whenever a theme's CSS does, for its ?v= link. */
themeVersion: string | null;
}
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
export const THEME_ASSET_PREFIX = "_themes/";
/** The declared CSS file of each of the plugin's themes, by theme id. */
function themeFiles(plugin: AssetPlugin): Map<string, string> {
const files = new Map<string, string>();
const manifest = plugin.manifest as PluginManifest;
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.resolve(plugin.dir, theme.css);
if (isInside(plugin.dir, file)) files.set(theme.id, file);
}
return files;
}
function frontendEntry(
@@ -72,9 +89,16 @@ export function describePluginFrontend(
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
};
if (!plugin) return none;
const themes = [...themeFiles(plugin).values()].filter(fileExists);
const themeVersion =
themes.length > 0
? contentVersion(themes.sort(), plugin.manifest.version)
: null;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
@@ -96,7 +120,8 @@ export function describePluginFrontend(
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
if (!entry || !fileExists(entry.file))
return { ...none, locales, themeVersion };
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
@@ -111,6 +136,7 @@ export function describePluginFrontend(
css,
assetVersion,
locales: locales.sort(),
themeVersion,
};
}
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
if (clean.startsWith(THEME_ASSET_PREFIX)) {
// Only a declared theme file, which the loader checked on load.
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
const file = themeFiles(plugin).get(themeId);
if (!file || !clean.endsWith(".css")) return null;
root = path.dirname(file);
rest = path.basename(file);
} else if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
+21 -1
View File
@@ -1,5 +1,5 @@
/**
* The onboarding defaults of the bundled plugins, from
* The onboarding defaults and the default look of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
@@ -27,6 +27,7 @@ interface BundledIndex {
consent?: boolean;
enabledByEnv?: string[];
};
defaultLook?: boolean;
}
>;
}
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
return cached;
}
/**
* Bundled plugins whose layout and theme a fresh install shows. Core reads
* the default look from here so it never has to name a plugin.
*/
export function defaultLookPluginIds(): string[] {
const index = readBundledIndex();
if (!index) return [];
return Object.entries(index.plugins)
.filter(([, entry]) => entry?.defaultLook === true)
.map(([id]) => id)
.sort();
}
export function isDefaultLookPlugin(pluginId: string): boolean {
return defaultLookPluginIds().includes(pluginId);
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
@@ -96,6 +114,8 @@ export function initialPluginState(
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
+91 -1
View File
@@ -18,7 +18,11 @@ import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import {
isTermixCompatible,
parseManifest,
validateThemeCss,
} from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
signedTier?: KeyTier;
}
/**
* A plugin folder that did not load. Kept so the layout repair view can say
* why a chosen layout or theme is missing instead of only "not installed".
*/
export interface SkippedPlugin {
/** Manifest id when it could be read, else the folder or file name. */
id: string;
version: string | null;
layouts: string[];
themes: string[];
error: string;
}
export interface PluginLoaderOptions {
errorThreshold?: number;
errorWindowMs?: number;
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
onFailed?: (plugin: LoadedPlugin) => void;
}
/**
* Every theme file a manifest declares must sit inside the plugin and hold
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
* being served.
*/
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
for (const theme of manifest.contributes?.themes ?? []) {
const file = path.join(dir, theme.css);
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
);
}
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
if (errors.length > 0) {
throw new Error(
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
);
}
}
}
/** What can still be said about a plugin folder that failed to load. */
function describeSkipped(
dir: string,
name: string,
isArtifact: boolean,
error: unknown,
): SkippedPlugin {
const skipped: SkippedPlugin = {
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
version: null,
layouts: [],
themes: [],
error: error instanceof Error ? error.message : String(error),
};
if (isArtifact) return skipped;
try {
const raw = JSON.parse(
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
) as {
id?: unknown;
version?: unknown;
contributes?: { layouts?: unknown; themes?: unknown };
};
const ids = (list: unknown) =>
Array.isArray(list)
? list
.map((entry) => (entry as { id?: unknown })?.id)
.filter((id): id is string => typeof id === "string")
: [];
if (typeof raw.id === "string") skipped.id = raw.id;
if (typeof raw.version === "string") skipped.version = raw.version;
skipped.layouts = ids(raw.contributes?.layouts);
skipped.themes = ids(raw.contributes?.themes);
} catch {
// An unreadable manifest leaves only the folder name.
}
return skipped;
}
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
private skippedPlugins: SkippedPlugin[] = [];
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
@@ -113,6 +192,11 @@ export class PluginLoader {
return [...this.plugins.values()];
}
/** Plugin folders the last full load skipped, with the reason. */
skipped(): SkippedPlugin[] {
return [...this.skippedPlugins];
}
get(pluginId: string): LoadedPlugin | undefined {
return this.plugins.get(pluginId);
}
@@ -210,6 +294,8 @@ export class PluginLoader {
);
}
validatePluginThemes(dir, manifest);
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
@@ -258,6 +344,7 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
this.skippedPlugins = [];
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
@@ -322,6 +409,9 @@ export class PluginLoader {
}
loaded.push(plugin);
} catch (error) {
this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error),
);
pluginLogger.error(
`Skipping plugin directory ${entry.name} in ${root}`,
error instanceof Error ? error : new Error(String(error)),
+57
View File
@@ -0,0 +1,57 @@
/**
* Layouts and themes on the server side: which look a fresh install shows,
* and which installed looks could not load. Core names no plugin here; the
* default look comes from the bundled index's defaultLook flag.
*/
import { defaultLookPluginIds } from "./bundled-index.js";
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
export interface DefaultLook {
layout: string | null;
theme: string | null;
}
interface LookLoader {
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
skipped(): SkippedPlugin[];
}
/**
* The first default-look plugin's default layout (else its first one) and
* that layout's theme. A default-look plugin with themes only fills in the
* theme. Null where none is installed, and the browser uses its built-in.
*/
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
const look: DefaultLook = { layout: null, theme: null };
for (const id of defaultLookPluginIds()) {
const contributes = loader.get(id)?.manifest.contributes;
const layouts = contributes?.layouts ?? [];
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
if (layout && !look.layout) {
look.layout = layout.id;
look.theme ??= layout.defaultTheme ?? null;
}
if (!look.theme && contributes?.themes?.length) {
look.theme = contributes.themes[0].id;
}
}
return look;
}
/** Plugins with a layout or theme that were found but did not load. */
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
return loader
.skipped()
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
}
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
export function safeLayoutForced(): boolean {
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
return value === "1" || value === "true";
}
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
export const LAYOUT_SETTING = "ui.layout";
export const THEME_SETTING = "ui.theme";
+11 -1
View File
@@ -7,7 +7,9 @@
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
* image and can still be uninstalled: their files are deleted and the id is
* recorded in the settings table, so the shipped copy is ignored from then
* on and a reinstall downloads the plugin like any other.
* on and a reinstall downloads the plugin like any other. The one exception
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
* it can be disabled or swapped but never uninstalled.
*/
import crypto from "node:crypto";
@@ -15,6 +17,7 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import {
activatePlugin,
deactivatePlugin,
@@ -1154,6 +1157,13 @@ export async function uninstallPluginUnlocked(
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
if (shipped && isDefaultLookPlugin(pluginId)) {
throw new PluginManageError(
"The default look cannot be uninstalled",
409,
"DEFAULT_LOOK",
);
}
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
+1
View File
@@ -21,6 +21,7 @@ export {
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
validateThemeCss,
} from "@termix-ssh/plugin-sdk/manifest";
export type {