Compare commits

..
40 changed files with 15274 additions and 129 deletions

No files matched your search

+8
View File
@@ -5,6 +5,14 @@
### Added
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
- Plugins can say how they show in the mobile app
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
### Fixed
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
## 26.10.5
+3 -1
View File
@@ -288,7 +288,9 @@
"onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": ["OIDC_CLIENT_ID"]
"enabledByEnv": [
"OIDC_CLIENT_ID"
]
}
},
{
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -533,6 +533,13 @@
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "5",
"when": 1792945361809,
"tag": "0076_host_color_icon",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -533,6 +533,13 @@
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -498,6 +498,13 @@
"when": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1792945434651,
"tag": "0071_host_color_icon",
"breakpoints": true
}
]
}
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 1.0.22
Plugin API 1.13.0.
### Added
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
### Fixed
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
## 1.0.21
Plugin API 1.12.0.
+44 -2
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.21 | Plugin API 1.12.0
SDK 1.0.22 | Plugin API 1.13.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -175,7 +175,49 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
### Mobile app
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
plugin as having nothing to show (official plugins are mapped by the app itself).
```json
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
```
| Field | Notes |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
| `note` | A locale key or plain text the app shows next to the plugin. |
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
How a `webview` plugin runs in the app:
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
plugin list.
- Every request and socket goes through the app, which adds the login, proxy headers and client
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
activity). Other core routes answer 403, so call your own backend instead.
- Images load straight from the server, without proxy headers or a client certificate.
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
is too old for the range, it asks the user to update the app instead of opening the view. When the range
is for an older API major, it asks to update the plugin. Ask only for the API you use.
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
+55 -36
View File
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.mjs",
"src/frontend/index.js",
];
const MOBILE_ENTRIES = [
"src/mobile/index.tsx",
"src/mobile/index.ts",
"src/mobile/index.mjs",
"src/mobile/index.js",
];
/** Builds a bundle that runs in a browser against core's shared modules. */
async function buildBrowserBundle(entry, outfile, outDir) {
await esbuild.build({
entryPoints: [entry],
outfile,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
/**
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
// build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
const scanner = new Scanner({
sources: [
{
base: path.join(cwd, "src", "frontend"),
pattern: "**/*",
negated: false,
},
],
});
// The mobile bundle uses the same stylesheet.
const sources = ["frontend", "mobile"]
.map((dir) => path.join(cwd, "src", dir))
.filter((dir, index) => index === 0 || fs.existsSync(dir))
.map((base) => ({ base, pattern: "**/*", negated: false }));
const scanner = new Scanner({ sources });
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const emit = async (candidates, options) =>
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await esbuild.build({
entryPoints: [frontendEntry],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
await buildBrowserBundle(
frontendEntry,
path.join(outDir, "frontend.js"),
outDir,
);
}
// A bundle the mobile app loads in its WebView, next to the frontend one.
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
const mobileOut = manifest.contributes?.mobile?.entry;
if (mobileEntry && typeof mobileOut === "string") {
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
}
if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
// A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales",
// Built from src/mobile by termix-plugin build.
...(typeof raw.contributes?.mobile?.entry === "string"
? [raw.contributes.mobile.entry]
: []),
];
for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) {
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.21",
"version": "1.0.22",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -102,6 +102,10 @@
"./testing-host": {
"default": "./dist/host/testing-host.js"
},
"./frame-host": {
"default": "./dist/host/frame-host.js"
},
"./frame-host.css": "./dist/host/frame-host.css",
"./theme.css": "./dist/host/theme.css"
},
"files": [
@@ -1239,6 +1239,48 @@
}
}
}
},
"mobile": {
"type": "object",
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": {
"type": "string",
"enum": ["webview", "native", "none"],
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
},
"entry": {
"type": "string",
"pattern": "\\.js$",
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
},
"sizes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "enum": ["phone", "tablet"] },
"description": "webview only. Screen sizes the UI works at. Defaults to both."
},
"views": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
},
"feature": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
},
"note": {
"type": "string",
"minLength": 1,
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
}
}
}
}
},
+157 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.12.0";
export const PLUGIN_API_VERSION = "1.13.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -526,6 +526,36 @@ export interface PluginContributions {
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
mobile?: PluginMobileContribution;
}
/**
* - "webview": the app shows the plugin's UI from the server in a WebView.
* - "native": the app has its own screens for this plugin.
* - "none": nothing to show in the app.
*/
export const MOBILE_MODES = ["webview", "native", "none"] as const;
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
export const MOBILE_SIZES = ["phone", "tablet"] as const;
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
export interface PluginMobileContribution {
mode: PluginMobileMode;
/**
* webview: a bundle built for the app, in the same folder as the frontend
* bundle. Unset means the normal frontend runs in a phone sized frame.
*/
entry?: string;
/** webview: screen sizes the UI works at. Defaults to both. */
sizes?: PluginMobileSize[];
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
views?: string[];
/** native: the app feature that stands in for it. Defaults to the plugin id. */
feature?: string;
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
note?: string;
}
/** A field a protocol login carries besides username and password. */
@@ -765,6 +795,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"protocols",
"layouts",
"themes",
"mobile",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -946,10 +977,135 @@ export function validateManifest(manifest: unknown): string[] {
typeof m.id === "string" ? m.id : undefined,
errors,
);
if (isPlainObject(m.contributes)) {
validateMobile(
m.contributes,
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
errors,
);
}
return errors;
}
function folderOf(file: string): string {
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
return parts.slice(0, -1).join("/");
}
function validateMobile(
contributes: Record<string, unknown>,
frontend: string,
errors: string[],
): void {
const mobile = contributes.mobile;
if (mobile === undefined) return;
const where = "contributes.mobile";
if (!isPlainObject(mobile)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(
mobile,
["mode", "entry", "sizes", "views", "feature", "note"],
`"${where}"`,
errors,
);
const mode = mobile.mode;
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
return;
}
for (const key of ["entry", "sizes", "views"]) {
if (mobile[key] !== undefined && mode !== "webview") {
errors.push(`${where}.${key} is only for mode "webview"`);
}
}
if (mobile.feature !== undefined && mode !== "native") {
errors.push(`${where}.feature is only for mode "native"`);
}
if (
mobile.entry !== undefined &&
requireString(mobile.entry, `${where}.entry`, errors)
) {
const before = errors.length;
requireRelativePath(mobile.entry, `${where}.entry`, errors);
if (!mobile.entry.endsWith(".js")) {
errors.push(`${where}.entry must be a .js file`);
} else if (
errors.length === before &&
folderOf(mobile.entry) !== folderOf(frontend)
) {
// Core only serves the folder the frontend bundle sits in.
errors.push(
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
);
}
}
if (mobile.sizes !== undefined) {
const sizes = mobile.sizes;
if (!Array.isArray(sizes) || sizes.length === 0) {
errors.push(`${where}.sizes must be a non-empty array`);
} else {
const seen = new Set<unknown>();
for (const size of sizes) {
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
errors.push(
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
);
} else if (seen.has(size)) {
errors.push(`${where}.sizes duplicates "${String(size)}"`);
}
seen.add(size);
}
}
}
if (mobile.views !== undefined) {
const views = mobile.views;
if (!Array.isArray(views) || views.length === 0) {
errors.push(`${where}.views must be a non-empty array`);
} else {
const declared = new Set<string>();
for (const field of ["tabs", "panels", "dashboardCards"]) {
const list = contributes[field];
if (!Array.isArray(list)) continue;
for (const view of list) {
if (isPlainObject(view) && typeof view.id === "string") {
declared.add(view.id);
}
}
}
const seen = new Set<unknown>();
for (const view of views) {
if (typeof view !== "string" || !declared.has(view)) {
errors.push(
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
);
} else if (seen.has(view)) {
errors.push(`${where}.views duplicates "${view}"`);
}
seen.add(view);
}
}
}
if (
mobile.feature !== undefined &&
(typeof mobile.feature !== "string" ||
!LOOK_ID_PATTERN.test(mobile.feature))
) {
errors.push(
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
}
if (mobile.note !== undefined) {
requireString(mobile.note, `${where}.note`, errors);
}
}
function validateFeatures(features: unknown, errors: string[]): void {
if (features === undefined) return;
if (!Array.isArray(features)) {
+14 -6
View File
@@ -1,9 +1,11 @@
/**
* Builds the two pieces of the plugin SDK that come from core:
* Builds the pieces of the plugin SDK that come from core:
*
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into
* dist/host/core-classes.json the classes core's CSS has rules for
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
* dist/host/frame-host.css core's compiled CSS for that frame
*
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
@@ -46,6 +48,7 @@ await esbuild.build({
entryPoints: {
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
},
outdir: outDir,
bundle: true,
@@ -72,6 +75,9 @@ await esbuild.build({
// so the translated locale files it would list are simply none.
banner: { js: "const __termixNoGlob = () => ({});" },
define: {
// esbuild picks "development" for an unminified build, which sends core's
// requests to the Vite dev proxy on any server without a port.
"process.env.NODE_ENV": '"production"',
"import.meta.glob": "__termixNoGlob",
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
@@ -157,14 +163,16 @@ const rootSource =
const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources],
});
const coreClasses = [
...classNamesInCss(
optimize(compiler.build(scanner.scan()), { minify: true }).code,
),
].sort();
const coreCss = optimize(compiler.build(scanner.scan()), {
minify: true,
}).code;
const coreClasses = [...classNamesInCss(coreCss)].sort();
fs.writeFileSync(
path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`,
);
// The frame host has no Vite build to load core's CSS, so it gets it here.
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
+2
View File
@@ -16,6 +16,8 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
const INTERNAL_EXPORTS = new Set([
"./testing/setup",
"./testing-host",
"./frame-host",
"./frame-host.css",
"./tsconfig.plugin.json",
"./schema/manifest.schema.json",
"./theme.css",
+29
View File
@@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => {
expect(output).not.toContain("is declared but");
});
});
describe("termix-plugin validate mobile entry", () => {
function mobile(built: boolean) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
}),
);
if (built) {
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
}
return root;
}
it("reports a mobile entry that was not built", async () => {
expect(await problems(mobile(false))).toContain(
"manifest references dist/mobile.js, which does not exist",
);
});
it("finds a built mobile entry", async () => {
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
});
});
+2
View File
@@ -1316,6 +1316,8 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
];
for (const migration of sshDataMigrations) {
+3
View File
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22),
+3
View File
@@ -193,6 +193,9 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22),
+3
View File
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -30,6 +30,33 @@ export function applyHostKeyTypeUpdate(
}
}
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
/**
* Color and icon from a host write. A field left out stays as stored, so
* editors that don't know about them never clear them. Bad values store null.
*/
export function normalizeHostLookFields(values: Record<string, unknown>): {
color?: string | null;
icon?: string | null;
} {
const result: { color?: string | null; icon?: string | null } = {};
if (values.color !== undefined) {
result.color =
typeof values.color === "string" && HOST_COLOR.test(values.color)
? values.color.toLowerCase()
: null;
}
if (values.icon !== undefined) {
result.icon =
typeof values.icon === "string" && HOST_ICON.test(values.icon)
? values.icon
: null;
}
return result;
}
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
+3
View File
@@ -33,6 +33,7 @@ import {
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -335,6 +336,7 @@ router.post(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
@@ -912,6 +914,7 @@ router.put(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
+12 -1
View File
@@ -244,7 +244,9 @@ export async function listPreLoginPlugins(): Promise<
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does.
* frontends in the same order the server does. `engine` holds the
* manifest's `api` and `termix` ranges, so other clients (the mobile
* app) can tell whether they understand the plugin.
*
* On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose, and, for
@@ -303,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
const engine: { api?: string; termix?: string } = {};
let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest)
: undefined;
@@ -322,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
optionalDependencies?: Record<string, string>;
desktop?: unknown;
runtime?: unknown;
engine?: { api?: unknown; termix?: unknown };
};
if (typeof manifest?.engine?.api === "string") {
engine.api = manifest.engine.api;
}
if (typeof manifest?.engine?.termix === "string") {
engine.termix = manifest.engine.termix;
}
desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ??
@@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server",
engine,
contributes,
icon,
dependencies,
@@ -0,0 +1,95 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserDeleteAccountRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account and all of their data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body ?? {};
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
// Same cleanup as an admin delete, so plugins hear user.deleted too.
await deleteUserAndRelatedData(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
}
+3 -81
View File
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
try {
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
authLogger.warn(
"Rejected proxy authentication from an untrusted source",
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
{
operation: "trusted_proxy_auth_rejected",
sourceAddress,
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRecord = await findCurrentUser(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin) {
const adminCount = await createCurrentUserRepository().countAdmins();
if (adminCount <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
}
await createCurrentUserRepository().delete(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
registerUserDeleteAccountRoutes(router, authenticateJWT);
registerUserPasswordResetRoutes(router, { authManager });
+2
View File
@@ -73,6 +73,8 @@ async function exportRow(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
+2
View File
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import {
isNonEmptyString,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -571,6 +572,7 @@ export async function runBulkImport(
? "true"
: "false",
notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
@@ -214,3 +214,43 @@ describe("host.created", () => {
}
});
});
describe("host color and icon", () => {
async function stored(id: number) {
const [row] = await ctx.drizzle
.select({ color: hosts.color, icon: hosts.icon })
.from(hosts)
.where(eq(hosts.id, id));
return row;
}
it("saves them on create and keeps them when an update leaves them out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({
...base,
authType: "password",
password: "pw",
color: "#22C55E",
icon: "database",
});
expect(created.status).toBe(200);
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, color: null, icon: null });
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
});
});
@@ -8,6 +8,7 @@ import {
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined();
});
});
describe("normalizeHostLookFields", () => {
it("leaves both fields out when the write does not send them", () => {
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
});
it("keeps a hex color and a Lucide name", () => {
expect(
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
).toEqual({ color: "#3b82f6", icon: "server-cog" });
});
it("stores null for cleared or bad values", () => {
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
{ color: null, icon: null },
);
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
color: null,
icon: null,
});
});
});
@@ -488,6 +488,35 @@ describe("plugins route", () => {
}
});
it("lists a plugin's engine ranges to any user", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({
manifestJson: JSON.stringify({
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
}),
}),
);
state.plugins.set(
"bad-engine",
makePlugin({
id: "bad-engine",
manifestJson: JSON.stringify({ engine: { api: 2 } }),
}),
);
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
api: "^1.13",
termix: ">=26.10.6",
});
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
});
it("reports an enabled plugin that safe mode keeps off", async () => {
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
@@ -643,6 +672,7 @@ describe("plugins route", () => {
enabled: true,
state: "enabled",
runsOnServer: false,
engine: {},
contributes: null,
dependencies: {},
optionalDependencies: {},
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash: string;
}
>(),
adminCount: 1,
deleted: [] as string[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("bcryptjs", () => ({
default: {
compare: async (password: string, hash: string) =>
hash === `hash:${password}`,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
countAdmins: async () => state.adminCount,
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
state.deleted.push(userId);
},
}));
const { registerUserDeleteAccountRoutes } =
await import("../../../database/routes/user-delete-account-routes.js");
let handler: RequestHandler | null = null;
registerUserDeleteAccountRoutes(
{
delete: (_path: string, ...handlers: RequestHandler[]) => {
handler = handlers[handlers.length - 1];
},
} as unknown as import("express").Router,
(_req, _res, next) => next(),
);
async function deleteAccount(userId: string, body: Record<string, unknown>) {
const req = { userId, body } as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as Record<string, unknown> | null,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: Record<string, unknown>) {
this.jsonBody = payload;
return this;
},
};
await handler!(req, res as unknown as Response, () => {});
return res;
}
beforeEach(() => {
state.adminCount = 1;
state.deleted = [];
state.users = new Map([
[
"u1",
{
id: "u1",
username: "user",
isAdmin: false,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"sso1",
{
id: "sso1",
username: "sso",
isAdmin: false,
isExternal: true,
passwordHash: "hash:secret",
},
],
]);
});
describe("DELETE /delete-account", () => {
it("runs the full user cleanup", async () => {
const res = await deleteAccount("u1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["u1"]);
});
it("needs a password", async () => {
const res = await deleteAccount("u1", {});
expect(res.statusCode).toBe(400);
expect(state.deleted).toEqual([]);
});
it("refuses a wrong password", async () => {
const res = await deleteAccount("u1", { password: "nope" });
expect(res.statusCode).toBe(401);
expect(state.deleted).toEqual([]);
});
it("refuses external accounts", async () => {
const res = await deleteAccount("sso1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("refuses the last admin", async () => {
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("lets an admin go when another admin is left", async () => {
state.adminCount = 2;
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["admin1"]);
});
it("404s for an unknown user", async () => {
const res = await deleteAccount("ghost", { password: "secret" });
expect(res.statusCode).toBe(404);
});
});
+104
View File
@@ -650,6 +650,110 @@ describe("panels, dashboard cards and guest pages", () => {
});
});
describe("contributes.mobile", () => {
const tabs = [
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
];
it("accepts each mode with its own fields", () => {
for (const mobile of [
{
mode: "webview",
entry: "dist/mobile.js",
sizes: ["phone", "tablet"],
views: ["sample"],
note: "mobile.note",
},
{ mode: "webview" },
{ mode: "native", feature: "file-manager" },
{ mode: "none", note: "Not on phones" },
]) {
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
[],
);
}
});
it("needs a known mode and rejects unknown fields", () => {
const errors = validateManifest(
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
).join();
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
expect(
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
).toMatch(/"contributes\.mobile" must be an object/);
});
it("keeps webview and native fields to their own mode", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
mobile: {
mode: "native",
entry: "dist/mobile.js",
views: ["sample"],
},
},
}),
).join();
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
).join(),
).toMatch(/mobile\.feature is only for mode "native"/);
});
it("puts the entry beside the frontend bundle", () => {
const check = (entry: string, frontend?: string) =>
validateManifest(
base({
...(frontend ? { frontend } : {}),
contributes: { mobile: { mode: "webview", entry } },
}),
).join();
expect(check("dist/mobile.js")).toBe("");
expect(check("./dist/mobile.js")).toBe("");
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
});
it("checks sizes, views and the feature id", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
panels: [{ id: "side", titleKey: "nav.side" }],
mobile: {
mode: "webview",
sizes: ["phone", "phone", "watch"],
views: ["sample", "side", "missing"],
},
},
}),
).join();
expect(errors).toMatch(/sizes duplicates "phone"/);
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
expect(errors).toMatch(/views entries must be ids .*"missing"/);
expect(errors).not.toMatch(/"side"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
).join(),
).toMatch(/sizes must be a non-empty array/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
).join(),
).toMatch(/mobile\.feature must match/);
});
});
describe("named service providers", () => {
const provides = (names: unknown) => [
{
+8
View File
@@ -72,6 +72,10 @@ export type Host = {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
@@ -182,6 +186,10 @@ export interface HostData {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
+332
View File
@@ -0,0 +1,332 @@
/* eslint-disable react-refresh/only-export-components */
/**
* Runs one plugin outside the shell, for an embedder that shows a single
* plugin view in its own frame (the mobile app's web view).
*
* The plugin gets the real app object, SDK bridge and registries, so its
* views behave as they do in the web app. Shell calls go to the embedder.
*/
import { useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/frontend";
import i18n from "@/i18n/i18n";
import type { PluginSummary } from "@/api/plugins-api";
import { ThemeProvider, useTheme } from "@/components/theme-provider";
import { Toaster } from "@/components/sonner";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { setPluginThemes } from "@/look/theme-registry";
import { getTabType, type TabRenderProps } from "@/shell/tab-registry";
import { getPanel } from "@/shell/panel-registry";
import { getSSHHosts } from "@/main-axios";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge } from "./bridge";
import { setFrontendState, setPluginSummaries } from "./plugin-store";
import {
notifyShellReady,
resetShellBridge,
setShellCallbacks,
setShellHosts,
shellHost,
} from "./shell-bridge";
export interface FrameTheme {
/** Also the class put on <html>; the embedder supplies its tokens. */
id: string;
mode: "light" | "dark";
}
/** What the plugin asks of the shell, with hosts passed by id. */
export interface FrameShell {
openTab: (
hostId: string | null,
type: string,
options?: { label?: string; data?: Record<string, unknown> },
) => void;
openSingletonTab: (
type: string,
options?: { label?: string; data?: Record<string, unknown> },
) => void;
connectHost: (hostId: string, type?: string) => void;
closeTab: () => void;
renameTab: (label: string) => void;
openHostEditor?: (draft?: Record<string, unknown>) => void;
}
export interface FrameView {
/** A registered tab type or panel id. */
view: string;
hostId?: string;
data?: Record<string, unknown>;
}
export interface PluginFrameOptions {
root: HTMLElement;
summary: PluginSummary;
module: {
activate: (app: never) => unknown;
deactivate?: () => unknown;
};
view: FrameView;
theme: FrameTheme;
shell: FrameShell;
/** The plugin's strings: English, plus one translation when there is one. */
locales?: {
language?: string;
en?: Record<string, unknown>;
translated?: Record<string, unknown>;
};
guest?: boolean;
}
export interface PluginFrame {
/** The plugin's registered tab types and panels. */
views: () => { tabs: string[]; panels: string[] };
setView: (view: FrameView) => void;
setTheme: (theme: FrameTheme) => void;
/** Reloads the host list, then remounts the view. */
reload: () => Promise<void>;
dispose: () => Promise<void>;
}
const THEME_KEY = "termix-frame-theme";
function themeEntry(theme: FrameTheme) {
return {
id: theme.id,
label: theme.id,
mode: theme.mode,
swatch: { background: "", panel: "", text: "" },
};
}
let applyTheme: ((id: string) => void) | null = null;
function ThemeSync() {
const { setTheme } = useTheme();
useEffect(() => {
applyTheme = (id) => setTheme(id, false);
return () => {
applyTheme = null;
};
}, [setTheme]);
return null;
}
function FrameBody({
view,
shell,
version,
}: {
view: FrameView;
shell: TabRenderProps["shell"];
version: number;
}) {
const [, setTick] = useState(0);
// Re-render once hosts arrive, so a host view finds its host.
useEffect(() => {
const onChange = () => setTick((n) => n + 1);
window.addEventListener("termix:frame-hosts", onChange);
return () => window.removeEventListener("termix:frame-hosts", onChange);
}, []);
const tab = getTabType(view.view);
const host = view.hostId ? shellHost(view.hostId) : undefined;
if (tab) {
const Component = tab.component;
const record: Tab = {
id: `${view.view}-frame`,
instanceId: `${view.view}-frame`,
type: view.view,
label: view.view,
host,
data: view.data,
openedAt: 0,
} as Tab;
return (
<Component
key={`${view.view}:${view.hostId ?? ""}:${version}`}
tab={record}
host={host}
sshHost={
host
? (hostToSSHHost(host) as unknown as Record<string, unknown>)
: undefined
}
label={view.view}
isVisible
isFocusedPane
inSplit={false}
handleRef={null}
shell={shell}
/>
);
}
const panel = getPanel(view.view);
if (panel) {
const Component = panel.component;
return (
<Component
key={`${view.view}:${version}`}
active
shell={shell}
setEditing={() => {}}
placement="left"
/>
);
}
return null;
}
/** Activates the plugin and renders one of its views into `root`. */
export async function startPluginFrame(
options: PluginFrameOptions,
): Promise<PluginFrame> {
const { summary, shell: embedder } = options;
const pluginId = summary.id;
installPluginHostBridge();
resetShellBridge();
const hostId = (host: Host | null | undefined) =>
host ? String(host.id) : null;
const shell: TabRenderProps["shell"] = {
openTab: (host, type, opts) =>
embedder.openTab(hostId(host), type, {
label: opts?.label,
data: opts?.data,
}),
openSingletonTab: (type, opts) => embedder.openSingletonTab(type, opts),
connectHost: (host, type) => {
const id = hostId(host);
if (id) embedder.connectHost(id, type);
},
closeTab: () => embedder.closeTab(),
renameTab: (_id, label) => embedder.renameTab(label),
openRailView: (id) => embedder.openSingletonTab(id),
closeRailView: () => {},
openHostEditor: embedder.openHostEditor
? (draft) => embedder.openHostEditor?.(draft as Record<string, unknown>)
: undefined,
};
setShellCallbacks(shell);
setPluginSummaries([summary]);
try {
localStorage.setItem(THEME_KEY, options.theme.id);
} catch {
// Private mode; the default below still applies.
}
setPluginThemes([themeEntry(options.theme)]);
const locales = options.locales ?? {};
if (locales.en) {
i18n.addResourceBundle("en", pluginId, locales.en, true, true);
}
if (locales.language && locales.translated) {
i18n.addResourceBundle(
locales.language,
pluginId,
locales.translated,
true,
true,
);
}
if (locales.language) await i18n.changeLanguage(locales.language);
const manifest = {
id: pluginId,
name: summary.name,
version: summary.version,
icon: summary.icon,
dependencies: summary.dependencies,
optionalDependencies: summary.optionalDependencies,
contributes: summary.contributes ?? undefined,
} as unknown as PluginManifest;
const handle = createPluginApp(pluginId, manifest, summary.contributes, {
guest: options.guest,
});
await options.module.activate(handle.app as never);
setFrontendState(pluginId, "active");
const loadHosts = async () => {
try {
setShellHosts((await getSSHHosts()).map(sshHostToHost));
} catch {
setShellHosts([], true);
}
window.dispatchEvent(new Event("termix:frame-hosts"));
};
const onHostsChanged = () => void loadHosts();
window.addEventListener("termix:hosts-changed", onHostsChanged);
await loadHosts();
notifyShellReady();
let view = options.view;
let theme = options.theme;
let version = 0;
const root: Root = createRoot(options.root);
const render = () =>
root.render(
<ThemeProvider defaultTheme={theme.id} storageKey={THEME_KEY}>
<ThemeSync />
<SurfaceScope className="h-full">
<FrameBody view={view} shell={shell} version={version} />
</SurfaceScope>
<Toaster position="top-center" closeButton duration={4000} />
</ThemeProvider>,
);
render();
const mine = <T extends { pluginId?: string; id: string }>(items: T[]) =>
items.filter((item) => item.pluginId === pluginId).map((item) => item.id);
return {
views: () => {
const tabs = summary.contributes?.tabs ?? [];
const panels = summary.contributes?.panels ?? [];
return {
tabs: mine(
tabs.map((entry) => getTabType(entry.id)).filter((def) => !!def) as {
id: string;
pluginId?: string;
}[],
),
panels: mine(
panels.map((entry) => getPanel(entry.id)).filter((def) => !!def) as {
id: string;
pluginId?: string;
}[],
),
};
},
setView: (next) => {
view = next;
render();
},
setTheme: (next) => {
theme = next;
setPluginThemes([themeEntry(next)]);
applyTheme?.(next.id);
},
reload: async () => {
await loadHosts();
version += 1;
render();
},
dispose: async () => {
window.removeEventListener("termix:hosts-changed", onHostsChanged);
root.unmount();
try {
await options.module.deactivate?.();
} finally {
handle.dispose();
setFrontendState(pluginId, "inactive");
resetShellBridge();
}
},
};
}
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, screen, waitFor } from "@testing-library/react";
import { useHosts, useTabs } from "@termix-ssh/plugin-sdk/frontend";
import type { PluginSummary } from "@/api/plugins-api";
vi.mock("@/main-axios", async (importOriginal) => ({
...(await importOriginal<typeof import("@/main-axios")>()),
getSSHHosts: vi.fn(async () => [
{
id: 7,
name: "web-01",
ip: "10.0.0.7",
port: 22,
username: "root",
pluginSettings: { gizmo: { enableGizmo: true } },
},
]),
}));
import {
startPluginFrame,
type FrameShell,
type PluginFrame,
} from "@/plugin-host/frame-host";
import { resetPluginStore } from "@/plugin-host/plugin-store";
import { resetTabTypes } from "@/shell/tab-registry";
const summary: PluginSummary = {
id: "gizmo",
name: "Gizmo",
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
contributes: {
tabs: [
{
id: "gizmo-home",
titleKey: "nav.home",
icon: "Box",
openFrom: ["rail"],
},
{
id: "gizmo-host",
titleKey: "nav.host",
icon: "Server",
openFrom: ["host-context-menu"],
},
],
},
};
function Home() {
const { hosts } = useHosts();
const tabs = useTabs();
return (
<div>
<p>hosts: {hosts.map((host) => host.name).join(",")}</p>
<button onClick={() => tabs.connectHost(hosts[0] as never)}>
connect
</button>
<button onClick={() => tabs.openTab(hosts[0] as never, "gizmo-host")}>
open
</button>
</div>
);
}
function HostView({
host,
sshHost,
}: {
host?: { name: string };
sshHost?: { pluginSettings?: { gizmo?: { enableGizmo?: boolean } } };
}) {
const on = sshHost?.pluginSettings?.gizmo?.enableGizmo === true;
return (
<p>
host view: {host?.name ?? "none"} {on ? "on" : "off"}
</p>
);
}
const module = {
activate: (app: {
registerTab: (type: string, component: unknown, options?: object) => void;
}) => {
app.registerTab("gizmo-home", Home, { hostless: true });
app.registerTab("gizmo-host", HostView, { requiresHost: true });
},
deactivate: vi.fn(),
};
function makeShell(): FrameShell & { calls: unknown[][] } {
const calls: unknown[][] = [];
const record =
(name: string) =>
(...args: unknown[]) => {
calls.push([name, ...args]);
};
return {
calls,
openTab: record("openTab"),
openSingletonTab: record("openSingletonTab"),
connectHost: record("connectHost"),
closeTab: record("closeTab"),
renameTab: record("renameTab"),
};
}
let root: HTMLElement;
let frame: PluginFrame | null = null;
beforeEach(() => {
root = document.createElement("div");
document.body.appendChild(root);
});
afterEach(async () => {
if (frame) await act(() => frame!.dispose());
frame = null;
root.remove();
resetPluginStore();
resetTabTypes();
});
describe("startPluginFrame", () => {
it("renders the chosen view with the server's hosts", async () => {
const shell = makeShell();
frame = await act(() =>
startPluginFrame({
root,
summary,
module: module as never,
view: { view: "gizmo-home" },
theme: { id: "dracula", mode: "dark" },
shell,
}),
);
await waitFor(() => expect(screen.getByText("hosts: web-01")).toBeTruthy());
expect(document.documentElement.classList.contains("dracula")).toBe(true);
expect(frame.views()).toEqual({
tabs: ["gizmo-home", "gizmo-host"],
panels: [],
});
});
it("passes shell calls to the embedder by host id", async () => {
const shell = makeShell();
frame = await act(() =>
startPluginFrame({
root,
summary,
module: module as never,
view: { view: "gizmo-home" },
theme: { id: "light", mode: "light" },
shell,
}),
);
await waitFor(() => screen.getByText("hosts: web-01"));
act(() => screen.getByText("connect").click());
act(() => screen.getByText("open").click());
expect(shell.calls).toEqual([
["connectHost", "7", undefined],
["openTab", "7", "gizmo-host", { label: undefined, data: undefined }],
]);
});
it("switches views and themes, and deactivates on dispose", async () => {
frame = await act(() =>
startPluginFrame({
root,
summary,
module: module as never,
view: { view: "gizmo-host", hostId: "7" },
theme: { id: "dark", mode: "dark" },
shell: makeShell(),
}),
);
await waitFor(() => screen.getByText("host view: web-01 on"));
act(() => frame!.setView({ view: "gizmo-home" }));
await waitFor(() => screen.getByText("hosts: web-01"));
act(() => frame!.setTheme({ id: "nord", mode: "dark" }));
await waitFor(() =>
expect(document.documentElement.classList.contains("nord")).toBe(true),
);
await act(() => frame!.dispose());
frame = null;
expect(module.deactivate).toHaveBeenCalled();
expect(root.textContent).toBe("");
});
});