mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda70dcc5d | ||
|
|
ab3edbdf7b | ||
|
|
f9a6b451c2 | ||
|
|
4d41a02efa | ||
|
|
0dfe3e22a9 | ||
|
|
ea0e614183 | ||
|
|
90f5aebafb | ||
|
|
1be507b13c | ||
|
|
76d733143a | ||
|
|
594fc19cbf | ||
|
|
bb448b6753 |
No files matched your search
@@ -5,6 +5,14 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
|
- 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
|
## 26.10.5
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,9 @@
|
|||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": false,
|
"recommended": false,
|
||||||
"consent": 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,
|
"when": 1792430772041,
|
||||||
"tag": "0075_session_policy",
|
"tag": "0075_session_policy",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1792430770990,
|
||||||
"tag": "0075_session_policy",
|
"tag": "0075_session_policy",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1792430769899,
|
||||||
"tag": "0070_session_policy",
|
"tag": "0070_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792945434651,
|
||||||
|
"tag": "0071_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.21
|
||||||
|
|
||||||
Plugin API 1.12.0.
|
Plugin API 1.12.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# 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
|
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.
|
`@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`,
|
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
`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
|
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.
|
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.mjs",
|
||||||
"src/frontend/index.js",
|
"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
|
* 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.
|
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||||
const compiler = () =>
|
const compiler = () =>
|
||||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||||
const scanner = new Scanner({
|
// The mobile bundle uses the same stylesheet.
|
||||||
sources: [
|
const sources = ["frontend", "mobile"]
|
||||||
{
|
.map((dir) => path.join(cwd, "src", dir))
|
||||||
base: path.join(cwd, "src", "frontend"),
|
.filter((dir, index) => index === 0 || fs.existsSync(dir))
|
||||||
pattern: "**/*",
|
.map((base) => ({ base, pattern: "**/*", negated: false }));
|
||||||
negated: false,
|
const scanner = new Scanner({ sources });
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||||
// on native CSS nesting.
|
// on native CSS nesting.
|
||||||
const emit = async (candidates, options) =>
|
const emit = async (candidates, options) =>
|
||||||
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
|
|||||||
|
|
||||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||||
if (frontendEntry) {
|
if (frontendEntry) {
|
||||||
await esbuild.build({
|
await buildBrowserBundle(
|
||||||
entryPoints: [frontendEntry],
|
frontendEntry,
|
||||||
outfile: path.join(outDir, "frontend.js"),
|
path.join(outDir, "frontend.js"),
|
||||||
bundle: true,
|
outDir,
|
||||||
format: "esm",
|
);
|
||||||
platform: "browser",
|
}
|
||||||
target: "es2022",
|
|
||||||
jsx: "automatic",
|
// A bundle the mobile app loads in its WebView, next to the frontend one.
|
||||||
sourcemap: true,
|
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
|
||||||
logLevel: "warning",
|
const mobileOut = manifest.contributes?.mobile?.entry;
|
||||||
external: FRONTEND_EXTERNALS,
|
if (mobileEntry && typeof mobileOut === "string") {
|
||||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
|
||||||
// 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 }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
|
|||||||
// A theme only plugin has no frontend code.
|
// A theme only plugin has no frontend code.
|
||||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||||
raw.locales ?? "locales",
|
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) {
|
for (const rel of referenced) {
|
||||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -102,6 +102,10 @@
|
|||||||
"./testing-host": {
|
"./testing-host": {
|
||||||
"default": "./dist/host/testing-host.js"
|
"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"
|
"./theme.css": "./dist/host/theme.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"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
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* 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. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
@@ -526,6 +526,36 @@ export interface PluginContributions {
|
|||||||
layouts?: PluginLayoutContribution[];
|
layouts?: PluginLayoutContribution[];
|
||||||
/** Themes. Needs the ui:theme capability. */
|
/** Themes. Needs the ui:theme capability. */
|
||||||
themes?: PluginThemeContribution[];
|
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. */
|
/** A field a protocol login carries besides username and password. */
|
||||||
@@ -765,6 +795,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"protocols",
|
"protocols",
|
||||||
"layouts",
|
"layouts",
|
||||||
"themes",
|
"themes",
|
||||||
|
"mobile",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SETTINGS_FIELD = [
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
@@ -946,10 +977,135 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
typeof m.id === "string" ? m.id : undefined,
|
typeof m.id === "string" ? m.id : undefined,
|
||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
|
if (isPlainObject(m.contributes)) {
|
||||||
|
validateMobile(
|
||||||
|
m.contributes,
|
||||||
|
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return 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 {
|
function validateFeatures(features: unknown, errors: string[]): void {
|
||||||
if (features === undefined) return;
|
if (features === undefined) return;
|
||||||
if (!Array.isArray(features)) {
|
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/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
||||||
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
* 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/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
|
* 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
|
* 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: {
|
entryPoints: {
|
||||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
"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,
|
outdir: outDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
@@ -72,6 +75,9 @@ await esbuild.build({
|
|||||||
// so the translated locale files it would list are simply none.
|
// so the translated locale files it would list are simply none.
|
||||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||||
define: {
|
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.glob": "__termixNoGlob",
|
||||||
"import.meta.env.DEV": "false",
|
"import.meta.env.DEV": "false",
|
||||||
"import.meta.env.PROD": "true",
|
"import.meta.env.PROD": "true",
|
||||||
@@ -157,14 +163,16 @@ const rootSource =
|
|||||||
const scanner = new Scanner({
|
const scanner = new Scanner({
|
||||||
sources: [...rootSource, ...compiler.sources],
|
sources: [...rootSource, ...compiler.sources],
|
||||||
});
|
});
|
||||||
const coreClasses = [
|
const coreCss = optimize(compiler.build(scanner.scan()), {
|
||||||
...classNamesInCss(
|
minify: true,
|
||||||
optimize(compiler.build(scanner.scan()), { minify: true }).code,
|
}).code;
|
||||||
),
|
const coreClasses = [...classNamesInCss(coreCss)].sort();
|
||||||
].sort();
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(outDir, "core-classes.json"),
|
path.join(outDir, "core-classes.json"),
|
||||||
`${JSON.stringify(coreClasses)}\n`,
|
`${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)}`);
|
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([
|
const INTERNAL_EXPORTS = new Set([
|
||||||
"./testing/setup",
|
"./testing/setup",
|
||||||
"./testing-host",
|
"./testing-host",
|
||||||
|
"./frame-host",
|
||||||
|
"./frame-host.css",
|
||||||
"./tsconfig.plugin.json",
|
"./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json",
|
"./schema/manifest.schema.json",
|
||||||
"./theme.css",
|
"./theme.css",
|
||||||
|
|||||||
@@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => {
|
|||||||
expect(output).not.toContain("is declared but");
|
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: "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: "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: "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) {
|
for (const migration of sshDataMigrations) {
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
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),
|
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||||
|
|
||||||
sshPort: int("ssh_port").default(22),
|
sshPort: int("ssh_port").default(22),
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export const hosts = pgTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
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),
|
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||||
|
|
||||||
sshPort: integer("ssh_port").default(22),
|
sshPort: integer("ssh_port").default(22),
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
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),
|
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||||
|
|
||||||
sshPort: integer("ssh_port").default(22),
|
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;
|
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||||
|
|
||||||
export function normalizeProtocolEnableFields(
|
export function normalizeProtocolEnableFields(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
isOptionalAuthType,
|
isOptionalAuthType,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeProtocolEnableFields,
|
normalizeProtocolEnableFields,
|
||||||
OWNER_PRIVATE_AUTH_FIELDS,
|
OWNER_PRIVATE_AUTH_FIELDS,
|
||||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||||
@@ -335,6 +336,7 @@ router.post(
|
|||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
...normalizeProtocolEnableFields(hostData),
|
...normalizeProtocolEnableFields(hostData),
|
||||||
|
...normalizeHostLookFields(hostData),
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -912,6 +914,7 @@ router.put(
|
|||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
...normalizeProtocolEnableFields(hostData),
|
...normalizeProtocolEnableFields(hostData),
|
||||||
|
...normalizeHostLookFields(hostData),
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||||
* `dependencies` and `optionalDependencies` let the loader activate
|
* `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
|
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||||
* only run on that server and stay off here on purpose, and, for
|
* 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 features: string[] = [];
|
||||||
let docs: string | undefined;
|
let docs: string | undefined;
|
||||||
let env: PluginEnvVar[] = [];
|
let env: PluginEnvVar[] = [];
|
||||||
|
const engine: { api?: string; termix?: string } = {};
|
||||||
let desktopMode: unknown = loaded
|
let desktopMode: unknown = loaded
|
||||||
? resolvePluginDesktopMode(loaded.manifest)
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -322,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
desktop?: unknown;
|
desktop?: unknown;
|
||||||
runtime?: 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
|
desktopMode = loaded
|
||||||
? resolvePluginDesktopMode(loaded.manifest)
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
: (manifest?.desktop ??
|
: (manifest?.desktop ??
|
||||||
@@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
? "safe-mode"
|
? "safe-mode"
|
||||||
: (loaded?.state ?? record.state),
|
: (loaded?.state ?? record.state),
|
||||||
runsOnServer: linked && desktopMode === "server",
|
runsOnServer: linked && desktopMode === "server",
|
||||||
|
engine,
|
||||||
contributes,
|
contributes,
|
||||||
icon,
|
icon,
|
||||||
dependencies,
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
|||||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||||
import { registerUserAdminRoutes } from "./user-admin-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 { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||||
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
"Rejected proxy authentication from an untrusted source",
|
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||||
{
|
{
|
||||||
operation: "trusted_proxy_auth_rejected",
|
operation: "trusted_proxy_auth_rejected",
|
||||||
sourceAddress,
|
sourceAddress,
|
||||||
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||||
* @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" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerUserPasswordResetRoutes(router, { authManager });
|
registerUserPasswordResetRoutes(router, { authManager });
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ async function exportRow(
|
|||||||
: resolvedHost.tags || [],
|
: resolvedHost.tags || [],
|
||||||
pin: !!resolvedHost.pin,
|
pin: !!resolvedHost.pin,
|
||||||
notes: resolvedHost.notes || null,
|
notes: resolvedHost.notes || null,
|
||||||
|
color: resolvedHost.color || null,
|
||||||
|
icon: resolvedHost.icon || null,
|
||||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||||
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
|
|||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
} from "../../database/routes/host-normalizers.js";
|
} from "../../database/routes/host-normalizers.js";
|
||||||
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||||
@@ -571,6 +572,7 @@ export async function runBulkImport(
|
|||||||
? "true"
|
? "true"
|
||||||
: "false",
|
: "false",
|
||||||
notes: hostData.notes || null,
|
notes: hostData.notes || null,
|
||||||
|
...normalizeHostLookFields(hostData as Record<string, unknown>),
|
||||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||||
socks5Host: hostData.socks5Host || null,
|
socks5Host: hostData.socks5Host || null,
|
||||||
socks5Port: hostData.socks5Port || 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,
|
keepsStoredSshAuth,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
normalizeProtocolEnableFields,
|
normalizeProtocolEnableFields,
|
||||||
renameFolderPath,
|
renameFolderPath,
|
||||||
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
|
|||||||
expect(impliedSshAuthType({})).toBeUndefined();
|
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 () => {
|
it("reports an enabled plugin that safe mode keeps off", async () => {
|
||||||
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
||||||
|
|
||||||
@@ -643,6 +672,7 @@ describe("plugins route", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
state: "enabled",
|
state: "enabled",
|
||||||
runsOnServer: false,
|
runsOnServer: false,
|
||||||
|
engine: {},
|
||||||
contributes: null,
|
contributes: null,
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
optionalDependencies: {},
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
describe("named service providers", () => {
|
||||||
const provides = (names: unknown) => [
|
const provides = (names: unknown) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export type Host = {
|
|||||||
terminalConfig?: HostTerminalConfig;
|
terminalConfig?: HostTerminalConfig;
|
||||||
sshOptions?: HostSshOptions;
|
sshOptions?: HostSshOptions;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
/** Hex color, like #3b82f6. */
|
||||||
|
color?: string | null;
|
||||||
|
/** Lucide icon name. */
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
useSocks5?: boolean;
|
useSocks5?: boolean;
|
||||||
socks5Host?: string;
|
socks5Host?: string;
|
||||||
@@ -182,6 +186,10 @@ export interface HostData {
|
|||||||
terminalConfig?: HostTerminalConfig;
|
terminalConfig?: HostTerminalConfig;
|
||||||
sshOptions?: HostSshOptions;
|
sshOptions?: HostSshOptions;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
/** Hex color, like #3b82f6. */
|
||||||
|
color?: string | null;
|
||||||
|
/** Lucide icon name. */
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
useSocks5?: boolean;
|
useSocks5?: boolean;
|
||||||
socks5Host?: string;
|
socks5Host?: string;
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user