mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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";
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
validateThemeCss,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
|
||||
Reference in new issue
Block a user