feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+103
View File
@@ -1,5 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
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";
@@ -48,11 +50,110 @@ export function externalRequireInterop(externals) {
};
}
const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"patches",
);
/**
* Patches a dependency before it is bundled, so the fix ships inside the
* plugin rather than depending on whoever ran npm install.
*
* package.json "termix": { "patches": ["xterm-android-ime"] } names patches
* the SDK ships for libraries several plugins share. Any .cjs file in the
* plugin's own patches/ folder runs too. Every patch must be safe to run
* twice.
*/
export function applyPatches(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const scripts = [];
for (const name of pkg.termix?.patches ?? []) {
const file = path.join(SDK_PATCHES, `${name}.cjs`);
if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) {
throw new Error(`${pluginId}: the SDK has no patch named "${name}"`);
}
scripts.push(file);
}
const own = path.join(cwd, "patches");
if (fs.existsSync(own)) {
for (const file of fs.readdirSync(own).sort()) {
if (file.endsWith(".cjs")) scripts.push(path.join(own, file));
}
}
for (const file of scripts) {
const result = spawnSync(process.execPath, [file], {
cwd,
env: { ...process.env, TERMIX_PATCH_ROOT: cwd },
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`${pluginId}: patch ${path.basename(file)} failed`);
}
}
}
const THEME_CSS = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"dist",
"host",
"theme.css",
);
/**
* Compiles the Tailwind classes the plugin's frontend uses into
* dist/frontend.css, against the theme core ships in the SDK. Core only
* compiles its own classes, so a class no core file happens to use would
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
* produced (a library's stylesheet the plugin imports).
*/
export async function buildTailwind(cwd, outDir) {
const { compile, optimize } = await import("@tailwindcss/node");
const { Scanner } = await import("@tailwindcss/oxide");
if (!fs.existsSync(THEME_CSS)) {
throw new Error("The SDK theme is missing. Run: npm run build:sdk");
}
const input = [
'@import "tailwindcss/theme.css" theme(reference);',
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
'@import "tailwindcss/utilities.css" layer(utilities);',
].join("\n");
const compiler = await compile(input, {
base: path.dirname(THEME_CSS),
onDependency: () => {},
});
const scanner = new Scanner({
sources: [
{
base: path.join(cwd, "src", "frontend"),
pattern: "**/*",
negated: false,
},
],
});
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const css = optimize(compiler.build(scanner.scan()), {
minify: true,
}).code.trim();
if (!css) return;
const target = path.join(outDir, "frontend.css");
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`);
}
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const outDir = path.join(cwd, "dist");
applyPatches(cwd, pluginId);
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
@@ -123,6 +224,8 @@ export async function build({ cwd }) {
});
}
if (frontendEntry) await buildTailwind(cwd, outDir);
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
+44 -4
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
import { findUnownedTableWrites } from "../../dist/ddl.js";
import { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -39,6 +39,7 @@ export async function validate({ cwd }) {
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
@@ -48,6 +49,36 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/**
* package.json has to agree with the manifest: the same version, and a real
* SDK range rather than "*", so a plugin built against a newer SDK says so.
*/
function validatePackage(cwd, raw) {
const problems = [];
const pkgPath = path.join(cwd, "package.json");
if (!fs.existsSync(pkgPath)) return problems;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.version && raw.version && pkg.version !== raw.version) {
problems.push(
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
);
}
const sdkRange =
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
pkg.dependencies?.["@termix/plugin-sdk"] ??
pkg.devDependencies?.["@termix/plugin-sdk"];
if (!sdkRange) {
problems.push("package.json does not depend on @termix/plugin-sdk");
} else if (sdkRange === "*" || sdkRange === "latest") {
problems.push(
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
);
}
return problems;
}
/**
* A native dependency has to actually be a real dependency of the plugin, or
* the build's external declaration points at nothing node_modules can
@@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) {
.filter((file) => file.endsWith(".sql"))
.sort();
byDialect.set(dialect, files);
const sqls = files.map((file) =>
fs.readFileSync(path.join(dir, file), "utf8"),
);
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
for (const file of files) {
for (const [index, file] of files.entries()) {
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
problems.push(
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
);
}
const sql = fs.readFileSync(path.join(dir, file), "utf8");
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) {
const sql = sqls[index];
for (const problem of findUnownedTableWrites(
pluginId,
sql,
legacy,
indexes,
)) {
problems.push(`migrations/${dialect}/${file} ${problem}`);
}
}
+4 -1
View File
@@ -6,7 +6,8 @@
*/
import process from "node:process";
import { build } from "./commands/build.mjs";
import { applyPatches, build } from "./commands/build.mjs";
import { readManifest } from "./lib/plugin-dir.mjs";
import { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs";
@@ -15,6 +16,7 @@ import { migrations } from "./commands/migrations.mjs";
const COMMANDS = {
build,
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
validate,
test,
pack,
@@ -32,6 +34,7 @@ if (!command || command === "--help" || command === "-h") {
"Usage: termix-plugin <command>",
"",
" build Bundle the plugin into dist/",
" patch Apply the plugin's dependency patches (build does this too)",
" validate Check manifest.json and the files it names",
" test Run the plugin's vitest suite",
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
+4 -3
View File
@@ -6,6 +6,10 @@
* a second ssh2 would mean a second set of native bindings. A plugin may
* depend on anything outside these lists, and that does get bundled.
*
* A plugin lists the ones it uses as peerDependencies. Only add a package here
* when core itself ships it; a library one plugin needs belongs in that
* plugin's own dependencies, so it can update without a core release.
*
* Node builtins are external automatically through platform: "node".
*/
@@ -18,9 +22,6 @@ export const BACKEND_EXTERNALS = [
"multer",
"cookie-parser",
"axios",
"jszip",
"guacamole-lite",
"@anthropic-ai/sdk",
"drizzle-orm",
"drizzle-orm/*",
// Native image processing, loaded on first use by the terminal's image upload.
@@ -0,0 +1,76 @@
const fs = require("fs");
const path = require("path");
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const packageRoot = path.join(packageDir("guacamole-common-js"));
const bundlePaths = [
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
];
const oldFlushBlock =
" if (window.requestAnimationFrame && document.hasFocus())\n" +
" asyncFlush();\n" +
" else\n" +
" syncFlush();";
const newFlushBlock =
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
" // synchronously so Guacamole connections do not stall while waiting for\n" +
" // a frame callback that may never run.\n" +
" syncFlush();";
let patched = false;
let foundBundle = false;
for (const bundlePath of bundlePaths) {
if (!fs.existsSync(bundlePath)) {
console.log(
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
);
continue;
}
foundBundle = true;
let content = fs.readFileSync(bundlePath, "utf8");
if (content.includes(newFlushBlock)) continue;
if (!content.includes(oldFlushBlock)) {
console.log(
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
);
continue;
}
content = content.replace(oldFlushBlock, newFlushBlock);
fs.writeFileSync(bundlePath, content);
patched = true;
}
if (!foundBundle) {
console.log("[patch-guacamole-common-js] File not found, skipping");
process.exit(0);
}
if (!patched) {
console.log("[patch-guacamole-common-js] Already patched");
process.exit(0);
}
console.log(
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
);
@@ -0,0 +1,130 @@
const fs = require("node:fs");
const path = require("node:path");
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
// the package resolves the way the plugin's bundle will resolve it.
function packageDir(name) {
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
// Walked by hand: a package whose exports map hides package.json cannot be
// found with require.resolve.
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
}
}
const xtermDir = path.join(packageDir("@xterm/xterm"), "lib");
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
// composition on the previous word and replace it with a shorter value (for
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
//
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
// for ordinary typing (it reports keyCode 229 for all software-keyboard
// input, not just IME composition). That handler diffs the textarea value
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
// several overlapping callbacks each capture a stale oldValue, so the
// literal-substring search fails to match and the diff silently comes back
// empty - characters are dropped instead of sent. Swap in the same
// common-prefix diff used for composition-end above so a stale oldValue
// still yields the correct delta.
const patches = [
{
file: "xterm.mjs",
replacements: [
[
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
],
[
[
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
],
},
{
file: "xterm.js",
replacements: [
[
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
],
[
[
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
],
},
];
for (const { file, replacements } of patches) {
const filePath = path.join(xtermDir, file);
if (!fs.existsSync(filePath)) {
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
}
let source = fs.readFileSync(filePath, "utf8");
let changed = false;
for (const [original, patched] of replacements) {
if (source.includes(patched)) {
continue;
}
const originals = Array.isArray(original) ? original : [original];
const matched = originals.find((candidate) => source.includes(candidate));
if (!matched) {
throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`,
);
}
source = source.replace(matched, patched);
changed = true;
}
if (!changed) {
console.log(`[patch-xterm-android-ime] ${file} already patched`);
continue;
}
fs.writeFileSync(filePath, source);
console.log(`[patch-xterm-android-ime] Patched ${file}`);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Import forms `termix-plugin build` understands beyond plain modules. Picked
* up through tsconfig.plugin.json.
*/
/** A script copied next to the bundle; the import is its URL. */
declare module "*?url" {
const url: string;
export default url;
}
/** A stylesheet imported for its side effect; the build emits frontend.css. */
declare module "*.css";
+28 -4
View File
@@ -37,6 +37,10 @@
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
},
"./desktop": {
"types": "./dist/desktop.d.ts",
"default": "./dist/desktop.js"
},
"./manifest": {
"types": "./dist/manifest.d.ts",
"default": "./dist/manifest.js"
@@ -70,29 +74,46 @@
"default": "./dist/ssh-certs.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
"./ui": {
"types": "./dist/host/ui.d.ts",
"default": "./dist/host/ui.js"
},
"./testing-host": {
"default": "./dist/host/testing-host.js"
},
"./theme.css": "./dist/host/theme.css"
},
"files": [
"dist",
"cli",
"schema",
"tsconfig.plugin.json",
"README.md"
"README.md",
"client.d.ts"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@tailwindcss/node": "^4.2.4",
"@tailwindcss/oxide": "^4.2.4",
"esbuild": "^0.28.1",
"semver": "^7.6.3",
"tailwindcss": "^4.2.4",
"tar": "^7.5.22"
},
"peerDependencies": {
"drizzle-orm": "*",
"ssh2": "*",
"@testing-library/react": "^16.3.3",
"better-sqlite3": "*",
"drizzle-orm": "*",
"i18next": "^26.4.2",
"react": "*",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.14",
"sonner": "^2.0.8",
"ssh2": "*",
"vitest": "*"
},
"peerDependenciesMeta": {
@@ -110,6 +131,9 @@
},
"vitest": {
"optional": true
},
"@testing-library/react": {
"optional": true
}
},
"publishConfig": {
+52 -12
View File
@@ -82,12 +82,12 @@
"termix": {
"type": "string",
"minLength": 1,
"description": "Display string only. Never enforced."
"description": "Semver range of Termix releases the plugin runs on, e.g. \">=2.9.0\"."
},
"api": {
"type": "string",
"pattern": "^[0-9]+$",
"description": "Plugin SDK major version. The real compatibility gate."
"minLength": 1,
"description": "Semver range of the plugin API it needs, e.g. \"1\" or \"^1.2\"."
}
}
},
@@ -115,6 +115,8 @@
"notify:send",
"audit:read",
"desktop:window",
"users:impersonate",
"db:core-refs",
"hosts:read",
"db:own",
"kv:own",
@@ -290,7 +292,9 @@
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,31}$",
"not": { "enum": ["ssh"] }
"not": {
"enum": ["ssh"]
}
},
"credentialFields": {
"type": "array",
@@ -311,7 +315,9 @@
]
}
},
"secret": { "type": "boolean" }
"secret": {
"type": "boolean"
}
}
}
},
@@ -323,7 +329,9 @@
"hostLoginFallback": {
"type": "array",
"uniqueItems": true,
"items": { "enum": ["username", "password"] }
"items": {
"enum": ["username", "password"]
}
}
}
}
@@ -352,17 +360,28 @@
"type": "object",
"propertyNames": {
"pattern": "^[a-zA-Z][a-zA-Z0-9]{0,31}$",
"not": { "const": "type" }
"not": {
"const": "type"
}
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": { "enum": ["string", "boolean"] },
"required": { "type": "boolean" },
"pattern": { "type": "string" },
"maxLength": { "type": "integer", "minimum": 1 },
"type": {
"enum": ["string", "boolean"]
},
"required": {
"type": "boolean"
},
"pattern": {
"type": "string"
},
"maxLength": {
"type": "integer",
"minimum": 1
},
"syncEntity": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
@@ -894,6 +913,15 @@
"ownerOnly": {
"type": "boolean",
"description": "Only the host's owner may change it."
},
"secretKeys": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
},
"description": "JSON fields only: keys inside the stored object that hold secrets."
}
}
}
@@ -962,7 +990,9 @@
"type": "string",
"pattern": "^(/[A-Za-z0-9_.-]+)+$"
},
"status": { "enum": [307, 308] }
"status": {
"enum": [307, 308]
}
}
}
}
@@ -1041,6 +1071,16 @@
}
}
}
},
"uses": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"description": "Other plugins' action, slot and extension point ids this plugin calls, fills or extends.",
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
}
}
}
},
+35 -12
View File
@@ -96,8 +96,8 @@ export interface PluginFiles {
* to query through `db`. Both require db:own.
*
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
* users, ssh_data, roles and user_roles read-only so a plugin can join against
* them without being able to write them. In-process code could reach around
* users, ssh_data, roles and user_roles (behind db:core-refs) so a plugin can
* join against them. In-process code could reach around
* all of this. The capability, the prefix, lint and review are the contract,
* not a sandbox.
*/
@@ -106,7 +106,10 @@ export interface PluginDatabase {
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
/** Drizzle handle, scoped to this plugin's tables. */
client: <T = unknown>() => Promise<T>;
/** Read-only references to the core tables a plugin may point at. */
/**
* Core tables a plugin may join against: users, hosts, roles, userRoles.
* Needs db:core-refs. Treat them as read-only.
*/
refs: <T = unknown>() => Promise<T>;
/**
* Flushes writes to disk. Call it after every write: on SQLite the database
@@ -235,6 +238,11 @@ export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
}
/**
* A plain key/value hand-off between plugins. `provide` and `revoke` only take
* keys under the plugin's own id (`<pluginId>.something`); anyone may
* `consume`.
*/
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
@@ -252,7 +260,11 @@ export interface PluginServices {
implementation: T,
options?: { name?: string },
) => void;
/** `provider` picks a named provider; omitted, the unnamed one. */
/**
* The service must be listed in the manifest's `requires` (or be one this
* plugin provides). `provider` picks a named provider; omitted, the unnamed
* one. A `userId` other than the acting user needs users:impersonate.
*/
get: <T extends object>(
service: string,
options?: { userId?: string; provider?: string },
@@ -1340,6 +1352,12 @@ export interface PluginLoginInstance {
* route ("oidc", "github", "google", "ldap"). Leave unset otherwise.
*/
type?: string;
/**
* Redirect methods: the login page starts this instance without a click,
* for a server that signs everyone in through one provider. The first
* instance that asks wins.
*/
autoStart?: boolean;
}
export interface PluginLoginMethod {
@@ -1468,7 +1486,13 @@ export interface PluginOpenIsolatedWindowRequest {
* Electron only: rejects when the server is not running embedded in the
* desktop app. Needs desktop:window.
*/
export interface PluginNativeRdpRequest {
/**
* A host to open in the operating system's own client for a protocol. The
* desktop app supports "rdp" (mstsc on Windows) today; any other protocol is
* refused.
*/
export interface PluginExternalClientRequest {
protocol: string;
host: string;
port?: number;
username?: string;
@@ -1480,12 +1504,11 @@ export interface PluginDesktop {
request: PluginOpenIsolatedWindowRequest,
) => Promise<{ success: true }>;
/**
* Opens the operating system's own RDP client (mstsc on Windows) for a
* host. The password is never passed; the client asks for it. Windows
* desktop app only.
* Opens the operating system's own client for a protocol. The password is
* never passed; the client asks for it. Desktop app only.
*/
launchNativeRdp: (
request: PluginNativeRdpRequest,
launchExternalClient: (
request: PluginExternalClientRequest,
) => Promise<{ success: boolean; error?: string }>;
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
available: () => boolean;
@@ -1942,8 +1965,8 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* This is the only way a plugin can name a user. Nothing else trusts a user
* id that came from plugin code.
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
+2
View File
@@ -44,6 +44,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
+171 -6
View File
@@ -429,7 +429,35 @@ const REFUSED_STATEMENTS = new Set([
/** What a migration may CREATE, ALTER or DROP. Anything else is refused. */
const TABLE_OBJECTS = new Set(["TABLE", "VIEW"]);
const HARMLESS_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/** Allowed, but only under the plugin's own prefix. */
const PREFIXED_OBJECTS = new Set(["SEQUENCE", "TYPE"]);
/**
* Functions that read files, change server settings or run a string as SQL.
* A SELECT calling one of them could do anything, so it is refused.
*/
const REFUSED_FUNCTIONS = new Set([
"SET_CONFIG",
"PG_READ_FILE",
"PG_READ_BINARY_FILE",
"PG_LS_DIR",
"PG_STAT_FILE",
"PG_FILE_WRITE",
"LO_IMPORT",
"LO_EXPORT",
"LO_FROM_BYTEA",
"DBLINK",
"DBLINK_EXEC",
"QUERY_TO_XML",
"QUERY_TO_XML_AND_XMLSCHEMA",
"PG_TERMINATE_BACKEND",
"PG_CANCEL_BACKEND",
"PG_RELOAD_CONF",
"LOAD_FILE",
"LOAD_EXTENSION",
"SYS_EXEC",
"SYS_EVAL",
]);
const CREATE_MODIFIERS = [
"OR",
@@ -460,9 +488,27 @@ export function findUnownedTableWrites(
pluginId: string,
sql: string,
ownedLegacy: ReadonlySet<string> = new Set(),
ownedIndexes: ReadonlySet<string> = new Set(),
): string[] {
const prefix = tablePrefix(pluginId);
const problems: string[] = [];
const indexes = new Set([
...ownedIndexes,
...collectOwnedIndexes(pluginId, [sql], ownedLegacy),
]);
// The splitter and tokenizer follow standard quoting only. MySQL backslash
// escapes, E'' strings and Postgres dollar quoting would let a string end
// somewhere else for the engine than for this check, so none are allowed.
if (sql.includes("\\")) {
problems.push("backslashes are not allowed in a plugin migration");
}
if (/\$[A-Za-z_0-9]*\$/.test(sql)) {
problems.push(
"dollar-quoted strings are not allowed in a plugin migration",
);
}
if (problems.length > 0) return problems;
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
@@ -507,16 +553,47 @@ export function findUnownedTableWrites(
continue;
}
if (first !== "CREATE" && find("CASCADE") > 0) {
problems.push("CASCADE is not allowed in a plugin migration");
continue;
}
if (first === "CREATE" || first === "ALTER" || first === "DROP") {
let at = skip(1, CREATE_MODIFIERS);
const object = upper(at);
if (object === "INDEX") {
// The index name is free; the table after ON is what it writes to.
const on = find("ON", at);
if (on > 0) table(skip(on + 1, ["ONLY"]));
if (on > 0) {
table(skip(on + 1, ["ONLY"]));
} else if (first === "CREATE") {
problems.push("CREATE INDEX without ON is not allowed");
} else {
// DROP INDEX name: only an index this plugin created.
const nameAt = skip(at + 1, ["IF", "EXISTS", "CONCURRENTLY"]);
const name = tokens[nameAt];
if (
!name ||
name.kind === "punct" ||
tokens[nameAt + 1]?.value === "." ||
(!name.value.startsWith(prefix) && !indexes.has(name.value))
) {
problems.push(
`${first} INDEX "${name?.value ?? ""}" is not an index this plugin created`,
);
}
}
continue;
}
if (PREFIXED_OBJECTS.has(object)) {
const name = tokens[skip(at + 1, ["IF", "NOT", "EXISTS"])];
if (!name || !name.value.startsWith(prefix)) {
problems.push(
`${first} ${object} "${name?.value ?? ""}" is not prefixed "${prefix}"`,
);
}
continue;
}
if (HARMLESS_OBJECTS.has(object)) continue;
if (!TABLE_OBJECTS.has(object)) {
problems.push(
`${first} ${object || tokens[at]?.value || ""} is not allowed in a plugin migration`.trim(),
@@ -565,6 +642,14 @@ export function findUnownedTableWrites(
}
if (first === "UPDATE") {
// MySQL writes every table before SET: UPDATE a JOIN b ... SET b.x = 1.
const set = find("SET");
const end = set > 0 ? set : tokens.length;
for (let at = 1; at < end; at++) {
if (upper(at) === "JOIN" || tokens[at].value === ",") {
table(at + 1);
}
}
table(
skip(1, [
"OR",
@@ -582,12 +667,51 @@ export function findUnownedTableWrites(
if (first === "DELETE") {
const from = find("FROM");
if (from > 0) table(skip(from + 1, ["ONLY"]));
else problems.push("DELETE without FROM is not allowed");
if (from < 0) {
problems.push("DELETE without FROM is not allowed");
continue;
}
// MySQL: DELETE a, b FROM ... deletes from a and b.
let at = skip(1, ["LOW_PRIORITY", "QUICK", "IGNORE"]);
if (at < from) {
while (at < from) {
at = table(at);
if (tokens[at]?.value !== ",") break;
at++;
}
continue;
}
at = table(skip(from + 1, ["ONLY"]));
// DELETE FROM a, b USING ... deletes from both.
while (tokens[at]?.value === ",") at = table(at + 1);
continue;
}
if (first !== "SELECT" && first !== "COMMENT") {
if (first === "SELECT") {
// SELECT ... INTO creates a table on Postgres and writes a file on
// MySQL.
if (find("INTO") > 0) {
problems.push("SELECT INTO is not allowed in a plugin migration");
continue;
}
for (let at = 0; at < tokens.length; at++) {
if (REFUSED_FUNCTIONS.has(upper(at)) && tokens[at + 1]?.value === "(") {
problems.push(
`${tokens[at].value}() is not allowed in a plugin migration`,
);
}
}
// setval can break a core table's ids, so only the plugin's own.
for (const match of statement.matchAll(/setval\s*\(\s*([^,]*)/gi)) {
const target = /'([^']*)'/.exec(match[1])?.[1];
if (!target || !target.startsWith(prefix)) {
problems.push("setval() may only target this plugin's own sequences");
}
}
continue;
}
if (first !== "COMMENT") {
problems.push(
`"${tokens[0].value}" statements are not allowed in a plugin migration`,
);
@@ -596,3 +720,44 @@ export function findUnownedTableWrites(
return problems;
}
/**
* Names of the indexes a plugin's migrations create on its own tables, so a
* later DROP INDEX with no ON clause can be checked against them.
*/
export function collectOwnedIndexes(
pluginId: string,
sqls: readonly string[],
ownedLegacy: ReadonlySet<string> = new Set(),
): Set<string> {
const prefix = tablePrefix(pluginId);
const owned = new Set<string>();
for (const sql of sqls) {
for (const statement of splitStatements(sql)) {
const tokens = tokenize(statement);
const upper = (at: number) =>
tokens[at]?.kind === "word" ? tokens[at].value.toUpperCase() : "";
if (upper(0) !== "CREATE") continue;
let at = 1;
while (CREATE_MODIFIERS.includes(upper(at))) at++;
if (upper(at) !== "INDEX") continue;
at++;
while (["IF", "NOT", "EXISTS"].includes(upper(at))) at++;
const name = tokens[at];
let on = at;
while (on < tokens.length && upper(on) !== "ON") on++;
if (upper(on + 1) === "ONLY") on++;
const target = tokens[on + 1];
if (
name &&
name.kind !== "punct" &&
target &&
tokens[on + 2]?.value !== "." &&
(target.value.startsWith(prefix) || ownedLegacy.has(target.value))
) {
owned.add(name.value);
}
}
}
return owned;
}
+394
View File
@@ -0,0 +1,394 @@
/**
* The desktop app's bridge (window.electronAPI), typed for plugins. It exists
* only inside the Termix desktop app; check isElectron before calling it.
*/
export interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
[key: string]: unknown;
}
export interface ConnectionTestResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
export interface DialogOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
[key: string]: unknown;
}
export interface DialogResult {
canceled: boolean;
filePath?: string;
filePaths?: string[];
[key: string]: unknown;
}
export interface LocalDirectoryResult {
success: boolean;
path: string;
parent?: string;
entries: LocalFileEntry[];
error?: string;
}
export interface LocalCollectedFile {
path: string;
name: string;
relativePath: string;
size: number;
created?: string;
modified: string;
}
export type LocalPathMutationResult =
| ({ success: true } & Partial<LocalFileEntry>)
| { success: false; error?: string };
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
tunnelName?: string;
error?: string;
code?: string;
}>;
testC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
message?: string;
error?: string;
code?: string;
}>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
setC2SRelayPath?: (
relayPath: string,
) => Promise<{ success: boolean; error?: string }>;
onCloseActiveTab?: (callback: () => void) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
getLocalHomeDirectory?: () => Promise<string>;
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
statLocalPaths?: (
paths: string[],
) => Promise<
Array<
| (LocalFileEntry & { success: true })
| { success: false; path: string; error?: string }
>
>;
collectLocalFiles?: (paths: string[]) => Promise<{
success: boolean;
files: LocalCollectedFile[];
truncated?: boolean;
error?: string;
}>;
createLocalFolder?: (
parentPath: string,
folderName: string,
) => Promise<LocalPathMutationResult>;
renameLocalPath?: (
path: string,
newName: string,
) => Promise<LocalPathMutationResult>;
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
chmodLocalPath?: (
path: string,
permissions: string,
) => Promise<LocalPathMutationResult>;
openExternalEditor: (fileData: {
fileName: string;
content: string;
encoding?: "utf8" | "base64";
editorPath?: string | null;
}) => Promise<{
success: boolean;
editId?: string;
path?: string;
error?: string;
}>;
closeExternalEditor: (editId: string) => Promise<{
success: boolean;
error?: string;
}>;
onExternalEditorSaved?: (
callback: (payload: {
editId: string;
content: string;
encoding: "utf8";
path: string;
}) => void,
) => () => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
createTempFile: (fileData: {
fileName: string;
content: string;
encoding?: "base64" | "utf8";
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
createTempFolder: (folderData: {
folderName: string;
files: Array<{
relativePath: string;
content: string;
encoding?: "base64" | "utf8";
}>;
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
startDragToDesktop: (dragData: {
tempId: string;
fileName: string;
}) => Promise<{
success: boolean;
error?: string;
}>;
cleanupTempFile: (tempId: string) => Promise<{
success: boolean;
error?: string;
}>;
startLocalTerminal(dimensions: {
cols: number;
rows: number;
shell?: "default" | "wsl";
}): Promise<{ sessionId: string; shell: string }>;
readyLocalTerminal(sessionId: string): Promise<boolean>;
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
resizeLocalTerminal(
sessionId: string,
cols: number,
rows: number,
): Promise<boolean>;
closeLocalTerminal(sessionId: string): Promise<boolean>;
onLocalTerminalData(
sessionId: string,
callback: (data: string) => void,
): () => void;
onLocalTerminalExit(
sessionId: string,
callback: (exitCode: number) => void,
): () => void;
/** Local disk browsing for the dual-pane file manager (desktop only). */
localFs?: {
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
mkdir: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
createFile: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
rename: (
oldPath: string,
newName: string,
) => Promise<LocalFsResult<{ path: string }>>;
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
ensureDir: (
dirPath: string,
rootPath?: string,
) => Promise<LocalFsResult<{ path: string }>>;
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
};
/** Streamed local<->remote transfers driven by the main process. */
localTransfer?: {
/** The /plugin-api/<id> path that serves the streaming routes. */
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
upload: (
options: LocalUploadRequest,
) => Promise<LocalFsResult<{ bytes: number }>>;
download: (
options: LocalDownloadRequest,
) => Promise<LocalFsResult<{ path: string }>>;
cancel: (
transferId: string,
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
onProgress: (
callback: (payload: LocalTransferProgress) => void,
) => () => void;
};
}
export type LocalFsResult<T> =
({ success: true } & T) | { success: false; error: string; code?: string };
export interface LocalFsHomeInfo {
home: string;
separator: string;
platform: string;
}
export interface LocalFileEntry {
name: string;
path: string;
type: "file" | "directory" | "link" | "other";
created?: string;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
size: number;
modifiedTimestamp?: number;
linkTarget?: string;
hidden: boolean;
}
export interface LocalDirectoryListing {
path: string;
parent: string | null;
entries: LocalFileEntry[];
}
export interface LocalTrashResult {
trashed: number;
failed: Array<{ path: string; error: string }>;
}
export interface LocalWalkFile {
localPath: string;
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
relativePath: string;
size: number;
}
export interface LocalWalkResult {
files: LocalWalkFile[];
emptyDirs: string[];
totalBytes: number;
}
/**
* Which Termix backend a transfer talks to. The main process resolves the
* actual URL and credentials for the origin; the renderer never supplies them.
*/
export type LocalTransferOrigin = "local" | "remote";
export interface LocalUploadRequest {
transferId: string;
origin: LocalTransferOrigin;
fields: Record<string, string>;
localPath: string;
fileName: string;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalDownloadRequest {
transferId: string;
origin: LocalTransferOrigin;
body: Record<string, unknown>;
destPath: string;
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
rootPath: string;
expectedSize?: number;
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
overwrite?: boolean;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalTransferProgress {
transferId: string;
transferred: number;
total?: number;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
electronClipboard?: {
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
};
}
}
+43 -5
View File
@@ -155,9 +155,6 @@ export interface PluginTabRecord {
instanceId?: string;
/** A backend session a session tab should reattach to. */
restoredSessionId?: string | null;
/** A live shared session this tab joins instead of connecting its own. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
[key: string]: unknown;
}
@@ -192,6 +189,11 @@ export interface ShellApi {
options?: OpenTabOptions,
) => void;
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
/**
* Connects to a host the way clicking it in the host list does: its
* default connection, or `type` when given.
*/
connectHost: (host: PluginHostRecord, type?: string) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
/** Saves a quick-connect tab's host as a real host. Absent in some shells. */
@@ -212,7 +214,7 @@ export interface ShellApi {
export interface TabsApi extends Pick<
ShellApi,
"openTab" | "openSingletonTab" | "closeTab" | "openRailView"
"openTab" | "openSingletonTab" | "connectHost" | "closeTab" | "openRailView"
> {
/** The current tabs and split layout, or null before the shell mounts. */
getLayout: () => ShellLayout | null;
@@ -1085,6 +1087,15 @@ export interface PluginCoreApi {
listHosts: (pluginId: string) => Promise<PluginHostRecord[]>;
/** The user's stored credentials, without their secrets. */
listCredentials: () => Promise<PluginCredentialSummary[]>;
/** Tells the shell hosts were added or changed, so lists reload. */
notifyHostsChanged: () => void;
/** How the user wants host status shown: the brand accent, or green/red. */
getHostStatusColorScheme: () => "accent" | "status";
/**
* The token the desktop app's embedded backend accepts, for work the
* desktop main process does on the renderer's behalf. Null in a browser.
*/
getLocalAuthToken: () => string | null;
}
/** A stored credential as a picker needs it. Secrets never leave core. */
@@ -1114,8 +1125,17 @@ export interface HostStatusInfo {
let host: PluginHostBridge | null = null;
/** Called once by core. Not part of the plugin API. */
/**
* Called once by core, before any plugin loads. Not part of the plugin API:
* once set it cannot be swapped, so a plugin cannot replace the bridge every
* other plugin talks through.
*/
export function __setPluginHost(bridge: PluginHostBridge | null): void {
if (host && bridge !== host) {
throw new Error(
"@termix/plugin-sdk/frontend: the plugin host is already set",
);
}
host = bridge;
}
@@ -1211,6 +1231,21 @@ export function logActivity(
return requireHost().core.logActivity(type, hostId, hostName);
}
/** Tells the shell hosts were added or changed, so its lists reload. */
export function notifyHostsChanged(): void {
requireHost().core.notifyHostsChanged();
}
/** How the user wants host status shown: the brand accent, or green/red. */
export function getHostStatusColorScheme(): "accent" | "status" {
return requireHost().core.getHostStatusColorScheme();
}
/** The desktop app's local backend token, or null in a browser. */
export function getLocalAuthToken(): string | null {
return requireHost().core.getLocalAuthToken();
}
export function getHostPassword(
hostId: number,
field: "password" | "sudoPassword",
@@ -1583,3 +1618,6 @@ export function useConnectionRetry({
export type { PluginManifest } from "./manifest.js";
export type { ReactNode };
// The desktop bridge types, and the window.electronAPI global they declare.
export type * from "./desktop.js";
+104 -11
View File
@@ -14,10 +14,40 @@ import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
/**
* The SDK major version this build implements. engine.api is the real
* compatibility gate; engine.termix is a display string and is never enforced.
* The plugin API this build implements. A minor bump adds to the API and
* never removes; a major bump may break plugins.
*
* engine.api is a semver range checked against this ("1" means any 1.x,
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
export const PLUGIN_API_VERSION = "1.0.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
semver.major(PLUGIN_API_VERSION),
);
/** Whether this build's plugin API satisfies a manifest's engine.api. */
export function isApiCompatible(range: string): boolean {
return (
semver.validRange(range) !== null &&
semver.satisfies(PLUGIN_API_VERSION, range)
);
}
/**
* Whether a core version satisfies a manifest's engine.termix. A prerelease
* core (2.9.0-beta.1) counts as that release.
*/
export function isTermixCompatible(
range: string,
coreVersion: string | null,
): boolean {
if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true;
if (semver.validRange(range) === null) return false;
return semver.satisfies(semver.coerce(coreVersion)!.version, range);
}
export const PLUGIN_CATEGORIES = [
"Terminal",
@@ -59,14 +89,12 @@ export const RESERVED_PLUGIN_IDS: readonly string[] = [
/** A preset key is a plain property name, never a prototype one. */
const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** Service names are dotted, e.g. "ssh.transport". */
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/** Action ids name a frontend function, so segments may be camelCase. */
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
/**
* A plugin-relative permission name. Unlike a full id one segment is fine,
* and underscores are allowed in the first segment too ("manage_providers").
@@ -98,9 +126,12 @@ export interface PluginAuthor {
}
export interface PluginEngine {
/** Display string only, e.g. ">=2.9.0". Never enforced. */
/** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */
termix: string;
/** SDK major version as a string. The real gate. */
/**
* Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the
* lowest minor whose API you call, so older cores refuse it cleanly.
*/
api: string;
}
@@ -257,6 +288,11 @@ export interface PluginSettingsField {
shareRead?: "connect" | "view" | "edit" | "manage";
/** Host scope only: only the host's owner may change it. */
ownerOnly?: boolean;
/**
* JSON fields only: keys inside the stored object that hold secrets, such
* as a gateway password. Core clears them from exports like any secret.
*/
secretKeys?: string[];
/**
* Stored and validated like any field, but not drawn by the generic
* settings form, because the plugin edits it in its own UI (a host editor
@@ -344,6 +380,13 @@ export interface PluginContributions {
* off and a desktop can list them in its sync settings.
*/
syncEntities?: string[];
/**
* Other plugins' action, slot and extension point ids this plugin calls,
* fills or extends. Declaration only: nothing breaks when the owner is
* missing, but the coupling is written down where tools and people can
* see it.
*/
uses?: string[];
/**
* Keybinding actions this plugin adds to Appearance > Keybindings, with
* the parameters a saved binding carries. Core validates saved bindings
@@ -562,6 +605,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"uiPresets",
"auth",
"syncEntities",
"uses",
"keybindingActions",
"protocols",
]);
@@ -584,6 +628,7 @@ const ALLOWED_SETTINGS_FIELD = [
"defaultFrom",
"shareRead",
"ownerOnly",
"secretKeys",
];
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
@@ -738,9 +783,20 @@ function validateEngine(engine: unknown, errors: string[]): void {
}
rejectUnknown(engine, ["termix", "api"], '"engine"', errors);
requireString(engine.termix, "engine.termix", errors);
if (typeof engine.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
if (
typeof engine.termix === "string" &&
semver.validRange(engine.termix) === null
) {
errors.push(
`Field "engine.api" must be an integer string, got: ${JSON.stringify(engine.api)}`,
`Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`,
);
}
if (
typeof engine.api !== "string" ||
semver.validRange(engine.api) === null
) {
errors.push(
`Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`,
);
}
}
@@ -895,6 +951,28 @@ function validateSyncEntities(value: unknown, errors: string[]) {
}
}
function validateUses(value: unknown, errors: string[]) {
const where = "contributes.uses";
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
return;
}
const seen = new Set<string>();
for (const id of value) {
if (
typeof id !== "string" ||
!/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)
) {
errors.push(
`${where} entries must be dotted ids such as "terminal.open"`,
);
continue;
}
if (seen.has(id)) errors.push(`${where} duplicates "${id}"`);
seen.add(id);
}
}
function validateNameList(value: unknown, where: string, errors: string[]) {
if (!Array.isArray(value) || value.length === 0) {
errors.push(`${where} must be a non-empty array`);
@@ -1025,6 +1103,9 @@ function validateContributes(
if (contributes.syncEntities !== undefined) {
validateSyncEntities(contributes.syncEntities, errors);
}
if (contributes.uses !== undefined) {
validateUses(contributes.uses, errors);
}
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
@@ -1534,6 +1615,18 @@ function validateSettings(settings: unknown, errors: string[]): void {
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
}
if ("secretKeys" in raw) {
const keys = raw.secretKeys;
if (raw.type !== "json") {
errors.push(`${fieldAt}.secretKeys is only valid on json fields`);
} else if (
!Array.isArray(keys) ||
keys.length === 0 ||
keys.some((key) => typeof key !== "string" || key.length === 0)
) {
errors.push(`${fieldAt}.secretKeys must be a non-empty string array`);
}
}
});
}
for (const scope of ["admin", "user"] as const) {
@@ -1957,10 +2050,10 @@ export function parseManifest(raw: unknown): ParsedManifest {
const manifest = raw as PluginManifest;
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
if (!isApiCompatible(manifest.engine.api)) {
return {
errors: [
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
`Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`,
],
};
}
+43 -11
View File
@@ -68,7 +68,7 @@ import type {
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginNativeRdpRequest,
PluginExternalClientRequest,
PluginProtocolTarget,
PluginHostStatusEntry,
PluginNotification,
@@ -297,8 +297,8 @@ export interface FakePluginContext {
title?: string;
ignoreCert?: boolean;
}>;
/** Every ctx.desktop.launchNativeRdp call, in order. */
nativeRdpLaunches: PluginNativeRdpRequest[];
/** Every ctx.desktop.launchExternalClient call, in order. */
externalClientLaunches: PluginExternalClientRequest[];
/** Every ctx.credentials.resolveHostProtocol call, in order. */
credentialReads: Array<{ hostId: number; protocol: string }>;
/** Every ctx.credentials.createSshKey call, with the id it answered. */
@@ -361,6 +361,11 @@ export interface FakePluginContext {
runScheduled: () => Promise<void>;
/** Changes the acting user, as core's request middleware would. */
setActor: (userId: string | undefined) => void;
/**
* Runs `fn` as `userId`, the way a request from that user would. For test
* code standing in for a caller; unlike ctx.asUser it needs no capability.
*/
actAs: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** Provided or seeded services, by name or "<service>#<provider>". */
services: Map<string, object>;
}
@@ -497,7 +502,8 @@ export function createFakeContext(
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
const credentialReads: FakePluginContext["credentialReads"] = [];
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
@@ -695,10 +701,12 @@ export function createFakeContext(
registry: {
provide: (key, value) => {
assertOwnRegistryKey(pluginId, key);
registryProviders.set(key, value);
},
consume: (key) => registryProviders.get(key) as never,
revoke: (key, value) => {
assertOwnRegistryKey(pluginId, key);
if (!registryProviders.has(key)) return false;
if (value !== undefined && registryProviders.get(key) !== value) {
return false;
@@ -1128,8 +1136,8 @@ export function createFakeContext(
desktopWindows.push(request);
return { success: true };
},
launchNativeRdp: async (request) => {
nativeRdpLaunches.push(request);
launchExternalClient: async (request) => {
externalClientLaunches.push(request);
return { success: true };
},
available: () => options.desktopAvailable ?? false,
@@ -1309,7 +1317,7 @@ export function createFakeContext(
hostShares,
auth,
desktopWindows,
nativeRdpLaunches,
externalClientLaunches,
credentialReads,
createdSshKeys,
notifications,
@@ -1337,6 +1345,15 @@ export function createFakeContext(
setActor: (userId) => {
actor = userId;
},
actAs: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
services,
};
}
@@ -1477,6 +1494,11 @@ export function createMockCtx(
const gatedCtx: PluginContext = {
...ctx,
asUser: async (userId, fn) => {
require("users:impersonate");
return ctx.asUser(userId, fn);
},
db: {
define: async (definition) => {
require("db:own");
@@ -1487,7 +1509,7 @@ export function createMockCtx(
return guardedDb.client();
},
refs: async () => {
require("db:own");
require("db:core-refs");
return guardedDb.refs();
},
persist: async (persistOptions) => {
@@ -1745,9 +1767,9 @@ export function createMockCtx(
require("desktop:window");
return ctx.desktop.openIsolatedWindow(request);
},
launchNativeRdp: async (request) => {
launchExternalClient: async (request) => {
require("desktop:window");
return ctx.desktop.launchNativeRdp(request);
return ctx.desktop.launchExternalClient(request);
},
available: () => ctx.desktop.available(),
},
@@ -1882,7 +1904,7 @@ export interface TestDbOptions {
* Runs after the core stub tables exist and before the plugin's migrations,
* e.g. to create the legacy table an adoption migration renames.
*/
before?: (sqlite: TestSqlite) => void | Promise<void>;
before?: (sqlite: TestSqlite) => unknown;
/** Skip applying the migrations, to apply them by hand later. */
skipMigrations?: boolean;
}
@@ -2046,6 +2068,8 @@ export interface RenderWithAppOptions {
ready?: boolean;
/** Stands in for app.api and usePluginApi(), e.g. a stub of the routes. */
api?: import("./frontend.js").PluginApiClient;
/** What app.desktop.remoteServerUrl() answers. Read on every call. */
remoteServerUrl?: () => string | null | Promise<string | null>;
}
/** A call a plugin made on the shell, recorded instead of performed. */
@@ -2150,3 +2174,11 @@ export async function renderWithApp(
const host = (await import(/* @vite-ignore */ TEST_HOST)) as PluginTestHost;
return host.renderPlugin(plugin, options);
}
function assertOwnRegistryKey(pluginId: string, key: string): void {
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
throw new Error(
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
);
}
}
+4
View File
@@ -98,6 +98,7 @@ const offline = () =>
useTabs: () => ({
openTab: noop,
openSingletonTab: noop,
connectHost: noop,
closeTab: noop,
getLayout: () => null,
applyLayout: async () => ({ skipped: [] }),
@@ -142,6 +143,9 @@ const offline = () =>
setClientPreference: () => {},
listHosts: async () => [],
listCredentials: async () => [],
notifyHostsChanged: () => {},
getHostStatusColorScheme: () => "accent",
getLocalAuthToken: () => null,
},
};
+15 -6
View File
@@ -14,12 +14,11 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Walks up from a plugin directory to the repo that holds src/ui.
* Walks up from a plugin directory to a Termix checkout, if it is in one.
*
* Plugin frontends and their tests still reach core through the "@/" alias
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A
* plugin developed outside this repo has no src/ui to point at, which is
* exactly right: the alias stops resolving and the import fails loudly.
* Inside the repo the SDK's browser entries and the test host resolve to
* core's source, so a test exercises the real registries. Outside it they
* resolve to the builds the SDK ships in dist/host.
*/
function findRepoRoot(startDir: string): string | null {
let dir = startDir;
@@ -83,7 +82,13 @@ export function pluginVitestConfig(
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}
: {}),
: {
// Outside a Termix checkout: the UI kit resolves through the
// package, and the test host is the copy the SDK ships.
"@termix/plugin-host/testing": fileURLToPath(
new URL("./host/testing-host.js", import.meta.url),
),
}),
...options.alias,
};
@@ -112,6 +117,10 @@ export function pluginVitestConfig(
environment: "jsdom",
globals: true,
setupFiles: [setupFile],
// Run the SDK through Vite rather than plain Node, so its import
// of the test host sees the alias above and the plugin and the
// host share one copy of @termix/plugin-sdk/frontend.
server: { deps: { inline: [/@termix\/plugin-sdk/] } },
include: ["tests/frontend/**/*.test.{ts,tsx}"],
},
},
+4 -1
View File
@@ -5,6 +5,8 @@
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"types": ["node", "vitest/globals"],
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
@@ -24,5 +26,6 @@
"noPropertyAccessFromIndexSignature": false,
"allowUnusedLabels": true,
"allowUnreachableCode": true
}
},
"files": ["./client.d.ts"]
}
+185
View File
@@ -0,0 +1,185 @@
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
AlertDialogAction (value): ({ className, ...props }: AlertDialogActionProps & RefAttributes<HTMLButtonElement>) => Element
AlertDialogCancel (value): ({ className, ...props }: AlertDialogCancelProps & RefAttributes<HTMLButtonElement>) => Element
AlertDialogContent (value): ({ className, ...props }: AlertDialogContentProps & RefAttributes<HTMLDivElement>) => Element
AlertDialogDescription (value): ({ className, ...props }: AlertDialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
AlertDialogFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
AlertDialogOverlay (value): ({ className, ...props }: AlertDialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
BarSeriesItem (type): BarSeriesItem
BarSeriesProps (type): BarSeriesProps
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
ButtonProps (type): ButtonProps
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardGridCanvas (value): ({ slots, columns, editMode, renderCard, cardCatalog, onChange, cardLabel, className, }: CardGridCanvasProps) => Element
CardHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
ConnectionLogPanel (value): ({ isConnecting, isConnected, hasConnectionError, position, className, }: ConnectionLogPanelProps) => Element
ConnectionLogProvider (value): ({ children, }: { children: ReactNode; }) => Element
ConnectionOrigin (type): ConnectionOrigin
ConnectionScreen (value): ({ status, message, backgroundColor, attempt, maxAttempts, nextRetryInMs, onManualRetry, retryLabel, disconnectedMessage, extraActions, logPosition, emptyState, className, }: ConnectionScreenProps) => Element
ConnectionStage (type): ConnectionStage
ConnectionStatus (type): ConnectionStatus
CustomKeybinding (type): CustomKeybinding
Dialog (value): ({ ...props }: DialogProps) => Element
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
DialogDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
DialogFooter (value): ({ className, showCloseButton, children, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
DialogHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
DialogOverlay (value): ({ className, ...props }: DialogOverlayProps & RefAttributes<HTMLDivElement>) => Element
DialogPortal (value): ({ ...props }: DialogPortalProps) => Element
DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuGroup (value): ({ ...props }: DropdownMenuGroupProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuItem (value): ({ className, inset, variant, ...props }: DropdownMenuItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; variant?: "default" | "destructive"; }) => Element
DropdownMenuLabel (value): ({ className, inset, ...props }: DropdownMenuLabelProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuPortal (value): ({ ...props }: DropdownMenuPortalProps) => Element
DropdownMenuRadioGroup (value): ({ ...props }: DropdownMenuRadioGroupProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuRadioItem (value): ({ className, children, inset, ...props }: DropdownMenuRadioItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuSeparator (value): ({ className, ...props }: DropdownMenuSeparatorProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuShortcut (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>) => Element
DropdownMenuSub (value): ({ ...props }: DropdownMenuSubProps) => Element
DropdownMenuSubContent (value): ({ className, sideOffset, alignOffset, ...props }: DropdownMenuSubContentProps & RefAttributes<HTMLDivElement>) => Element
DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: DropdownMenuSubTriggerProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
FOLDER_COLORS (value): string[]
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
FullScreenAppPhase (type): FullScreenAppPhase
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridLayout (type): GridLayout
GridSlot (type): GridSlot
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
KeyCombo (type): KeyCombo
KeybindingAction (type): KeybindingAction
KeybindingActionType (type): string
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LogContext (type): LogContext
LogEntry (type): LogEntry
MFAPromptMode (type): MFAPromptMode
ManagerCardError (type): ManagerCardError
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
Popover (value): ({ ...props }: PopoverProps) => Element
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RecentActivityItem (type): RecentActivityItem
RobustClipboardProvider (value): typeof RobustClipboardProvider
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SectionCard (value): ({ title, icon, action, children, className, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; }) => Element
Select (value): ({ ...props }: SelectSharedProps & { value?: string; defaultValue?: string; onValueChange?(value: string): void; }) => Element
Select2 (value): ({ className, children, value, defaultValue, onChange, disabled, placeholder, align, contentClassName, name, id, required, "aria-label": ariaLabel, ...props }: Select2Props) => Element
SelectContent (value): ({ className, children, position, ...props }: SelectContentProps & RefAttributes<HTMLDivElement>) => Element
SelectGroup (value): ({ ...props }: SelectGroupProps & RefAttributes<HTMLDivElement>) => Element
SelectItem (value): ({ className, children, ...props }: SelectItemProps & RefAttributes<HTMLDivElement>) => Element
SelectLabel (value): ({ className, ...props }: SelectLabelProps & RefAttributes<HTMLDivElement>) => Element
SelectScrollDownButton (value): ({ className, ...props }: SelectScrollDownButtonProps & RefAttributes<HTMLDivElement>) => Element
SelectScrollUpButton (value): ({ className, ...props }: SelectScrollUpButtonProps & RefAttributes<HTMLDivElement>) => Element
SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAttributes<HTMLDivElement>) => Element
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
SettingRow (value): ({ label, badge, description, children, }: { label: string; badge?: string; description?: ReactNode; children: ReactNode; }) => Element
Sheet (value): ({ ...props }: DialogProps) => Element
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
SheetDescription (value): ({ className, ...props }: DialogDescriptionProps & RefAttributes<HTMLParagraphElement>) => Element
SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
SparklineProps (type): SparklineProps
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
Tooltip (value): ({ ...props }: TooltipProps) => Element
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
UptimeInfo (type): UptimeInfo
VersionInfo (type): VersionInfo
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getBasePath (value): () => string
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
getDeviceId (value): () => string
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
getUptime (value): () => Promise<UptimeInfo>
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
isElectron (value): () => boolean
isMacPlatform (value): () => boolean
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
linkedServerUrl (value): () => Promise<string>
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
readFromClipboard (value): () => Promise<string>
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
resolveRemoteHostId (value): (syncId: string) => Promise<number>
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
useAppTheme (value): () => ThemeProviderState
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
useConnectionLog (value): () => ConnectionLogContextType
useIsMobile (value): () => boolean
useOptionalConnectionLog (value): () => ConnectionLogContextType
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType