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

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

+36 -22
View File
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { staticUrlImports } from "../lib/static-url-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
import {
readManifest,
resolveEntry,
copyDir,
isThemeOnly,
} from "../lib/plugin-dir.mjs";
import { openapi } from "./openapi.mjs";
import { splitByCoreClasses } from "../lib/css-classes.mjs";
@@ -232,32 +237,41 @@ export async function build({ cwd }) {
fs.mkdirSync(outDir, { recursive: true });
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
if (!backendEntry) {
if (!backendEntry && !isThemeOnly(manifest)) {
throw new Error(
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
);
}
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: { js: REQUIRE_BANNER },
});
if (backendEntry) {
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: { js: REQUIRE_BANNER },
});
} else {
// Core loads every plugin through a backend entry, so a theme only
// plugin gets an empty one.
fs.writeFileSync(
path.join(outDir, "backend.js"),
"export function activate() {}\n",
);
}
for (const worker of readWorkerEntries(cwd, pluginId)) {
await esbuild.build({
+12 -2
View File
@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
import {
collectFiles,
createTmxplug,
themeFiles,
TMXPLUG_CONTENTS,
} from "../lib/tmxplug.mjs";
import { sha256 } from "../lib/signing.mjs";
import { validate } from "./validate.mjs";
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
fs.mkdirSync(outDir, { recursive: true });
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
const archive = createTmxplug(cwd, collectFiles(cwd));
const archive = createTmxplug(
cwd,
collectFiles(cwd, [
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
]),
);
fs.writeFileSync(outfile, archive);
console.log(`packed ${path.basename(outfile)}`);
+63 -3
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const { parseManifest } = await loadSdkModule("manifest");
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
const { errors } = parseManifest(raw);
const problems = [...errors];
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
// The manifest names files. They have to be there.
const referenced = [
raw.backend ?? "dist/backend.js",
raw.frontend ?? "dist/frontend.js",
// A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales",
];
for (const rel of referenced) {
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
}
}
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
problems.push(...validateLookCapabilities(cwd, raw));
problems.push(
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
);
@@ -53,6 +56,63 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/** Every declared theme file exists and holds only CSS a theme may hold. */
export function validateThemeFiles(cwd, raw, validateThemeCss) {
const problems = [];
const themes = raw?.contributes?.themes;
if (!Array.isArray(themes)) return problems;
for (const theme of themes) {
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
continue;
}
const file = path.join(cwd, theme.css);
if (!fs.existsSync(file)) {
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
continue;
}
for (const error of validateThemeCss(
fs.readFileSync(file, "utf8"),
theme.id,
)) {
problems.push(`theme ${theme.id}: ${error}`);
}
}
return problems;
}
/**
* ui:layout and ui:theme must match what the manifest contributes, both
* ways. The manifest check already flags a contribution without its
* capability; this flags a capability with nothing behind it, and a layout
* with no frontend to register it.
*/
export function validateLookCapabilities(cwd, raw) {
const problems = [];
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
const layouts = raw?.contributes?.layouts;
const themes = raw?.contributes?.themes;
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
const hasThemes = Array.isArray(themes) && themes.length > 0;
if (capabilities.includes("ui:layout") && !hasLayouts) {
problems.push('"ui:layout" is declared but contributes.layouts is empty');
}
if (capabilities.includes("ui:theme") && !hasThemes) {
problems.push('"ui:theme" is declared but contributes.themes is empty');
}
if (hasLayouts) {
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
const frontend = entries.some((entry) =>
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
);
if (!frontend && raw.frontend === undefined) {
problems.push(
"contributes.layouts needs a frontend that calls app.registerLayout",
);
}
}
return problems;
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
}
/** The first of these that exists, or null. */
/** Only themes: CSS files and no code, so no source entry is needed. */
export function isThemeOnly(manifest) {
const capabilities = manifest.capabilities ?? [];
return (
capabilities.length > 0 &&
capabilities.every((capability) => capability === "ui:theme") &&
Array.isArray(manifest.contributes?.themes) &&
manifest.contributes.themes.length > 0
);
}
export function resolveEntry(cwd, candidates) {
for (const candidate of candidates) {
const full = path.join(cwd, candidate);
+9
View File
@@ -2,6 +2,15 @@ import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/** Theme CSS files a manifest declares, which travel with the plugin too. */
export function themeFiles(manifest) {
const themes = manifest?.contributes?.themes;
if (!Array.isArray(themes)) return [];
return themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string");
}
/** What a .tmxplug holds, in the order the spec lists it. */
export const TMXPLUG_CONTENTS = [
"manifest.json",