mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dfe3e22a9 | ||
|
|
ea0e614183 | ||
|
|
90f5aebafb | ||
|
|
1be507b13c | ||
|
|
76d733143a | ||
|
|
594fc19cbf | ||
|
|
bb448b6753 |
No files matched your search
@@ -5,6 +5,9 @@
|
||||
### 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
|
||||
|
||||
## 26.10.5
|
||||
|
||||
|
||||
@@ -288,7 +288,9 @@
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
"enabledByEnv": ["OIDC_CLIENT_ID"]
|
||||
"enabledByEnv": [
|
||||
"OIDC_CLIENT_ID"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)}`);
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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) => [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/* 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 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}
|
||||
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,174 @@
|
||||
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" },
|
||||
]),
|
||||
}));
|
||||
|
||||
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 }: { host?: { name: string } }) {
|
||||
return <p>host view: {host?.name ?? "none"}</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"));
|
||||
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("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user