* 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

+109 -28
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,
@@ -31,6 +34,7 @@ import {
} from "./paths.js";
import {
OFFICIAL_REGISTRY_ID,
canRunRelease,
downloadRelease,
fetchRegistryIndex,
channelReleases,
@@ -39,6 +43,7 @@ import {
getRegistryStatus,
isApiCompatible,
latestOnChannel,
neededTermix,
officialSource,
parsePluginChannel,
type PluginChannel,
@@ -254,10 +259,13 @@ export interface RegistryListing {
videoId?: string;
features: string[];
docs?: string;
contributes: string[];
versions: Array<{
version: string;
prerelease: boolean;
compatible: boolean;
/** The Termix range this version needs, when this build is too old. */
needsTermix: string | null;
capabilities: string[];
publishedAt?: string;
releaseNotesUrl?: string;
@@ -393,7 +401,7 @@ export async function listRegistry(
const channel = channelOf(record);
const latest = latestOnChannel(plugin, channel);
const latestBeta =
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
plugin.prereleases.find((entry) => canRunRelease(entry)) ?? null;
const installedVersion =
live?.manifest.version ?? record?.version ?? null;
const updateAvailable = Boolean(
@@ -416,10 +424,12 @@ export async function listRegistry(
videoId: plugin.videoId,
features: plugin.features,
docs: plugin.docs,
contributes: plugin.contributes,
versions: channelReleases(plugin, "beta").map((entry) => ({
version: entry.version,
prerelease: Boolean(entry.prerelease),
compatible: isApiCompatible(entry.api),
compatible: canRunRelease(entry),
needsTermix: neededTermix(entry),
capabilities: entry.capabilities,
publishedAt: entry.publishedAt,
releaseNotesUrl: entry.releaseNotesUrl,
@@ -899,9 +909,12 @@ async function requireRelease(
"NOT_IN_REGISTRY",
);
}
if (!isApiCompatible(found.release.api)) {
if (!canRunRelease(found.release)) {
const termix = neededTermix(found.release);
throw new PluginManageError(
`${pluginId} ${found.release.version} does not run on this version of Termix`,
termix
? `${pluginId} ${found.release.version} needs Termix ${termix}`
: `${pluginId} ${found.release.version} does not run on this version of Termix`,
409,
"INCOMPATIBLE",
);
@@ -1154,6 +1167,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) {
@@ -1492,6 +1512,14 @@ function validateUpload(buffer: Buffer): PluginManifest {
"INCOMPATIBLE",
);
}
const termix = neededTermix({ engine: manifest.engine });
if (termix) {
throw new PluginManageError(
`${manifest.id} ${manifest.version} needs Termix ${termix}`,
409,
"INCOMPATIBLE",
);
}
return manifest;
}
@@ -1634,41 +1662,94 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
if (prepare) await prepare(previous);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
* dev runner.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
"This plugin has no bundled copy",
409,
"NOT_BUNDLED",
"NO_BUNDLED_COPY",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
});
}